├── server ├── db │ └── .gitkeep ├── data │ └── sounds │ │ └── .gitkeep ├── .env.example ├── .eslintrc ├── src │ ├── enum.ts │ ├── socket.ts │ ├── config.ts │ ├── utils.ts │ ├── types.ts │ ├── index.ts │ ├── soundupload.ts │ ├── users.ts │ ├── datastore.ts │ ├── endpoints.ts │ └── twitch.ts ├── tsconfig.json └── package.json ├── client ├── public │ ├── .gitkeep │ └── index.html ├── src │ ├── declarations.d.ts │ ├── theme │ │ ├── overrides │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── palette.ts │ │ └── typography.ts │ ├── components │ │ ├── player │ │ │ ├── index.ts │ │ │ └── PlayerMain.tsx │ │ ├── dashboard │ │ │ ├── DashboardMain.tsx │ │ │ ├── Layout.tsx │ │ │ ├── ConnectedCard.tsx │ │ │ ├── index.ts │ │ │ ├── IconHelper.tsx │ │ │ ├── ConnectionPage.tsx │ │ │ ├── UserPage.tsx │ │ │ ├── SoundPage.tsx │ │ │ ├── HomePage.tsx │ │ │ ├── UserTable.tsx │ │ │ ├── ConnectionForm.tsx │ │ │ ├── SoundTable.tsx │ │ │ ├── dialogs │ │ │ │ ├── EditUserDialog.tsx │ │ │ │ ├── NewUserDialog.tsx │ │ │ │ ├── EditSoundDialog.tsx │ │ │ │ └── NewSoundDialog.tsx │ │ │ └── Navigation.tsx │ │ └── App.tsx │ ├── enums │ │ ├── userFlags.ts │ │ └── accessLevels.ts │ ├── config.ts │ ├── hooks │ │ ├── index.ts │ │ ├── useRouter.ts │ │ ├── useUsers.tsx │ │ ├── useAppContext.tsx │ │ └── useSounds.tsx │ ├── index.tsx │ ├── index.less │ ├── types │ │ └── index.ts │ └── client.ts ├── .eslintrc ├── .babelrc ├── tsconfig.json ├── webpack.config.js ├── webpack.config.dev.js └── package.json ├── script ├── run.bat └── setup.bat ├── .gitignore └── README.md /server/db/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/data/sounds/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/theme/overrides/index.ts: -------------------------------------------------------------------------------- 1 | export default { } 2 | -------------------------------------------------------------------------------- /script/run.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | color 5 3 | cd ../server 4 | @echo on 5 | npm run server 6 | @pause -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *node_modules 2 | *build 3 | *dist 4 | *.env 5 | *.vscode 6 | 7 | *db-*.json 8 | 9 | *.mp3 -------------------------------------------------------------------------------- /client/src/components/player/index.ts: -------------------------------------------------------------------------------- 1 | import PlayerMain from './PlayerMain' 2 | 3 | export { 4 | PlayerMain 5 | } 6 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | SERVER_PORT="8080" 2 | UUID_NAMESPACE="00 02 32 ef 70 30 a8 ea e4 c5 21 69 95 da 8a c5" 3 | ENVIRONMENT="PRODUCTION" 4 | SOCKET_PORT="8081" -------------------------------------------------------------------------------- /client/src/enums/userFlags.ts: -------------------------------------------------------------------------------- 1 | import { UserFlags } from '../types' 2 | 3 | const userFlags: UserFlags[] = [ 4 | 'ban', 5 | 'all-access' 6 | ] 7 | 8 | export default userFlags 9 | -------------------------------------------------------------------------------- /server/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "jubic-typescript", 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "rules": { 7 | "import/no-duplicates": 0, 8 | } 9 | } -------------------------------------------------------------------------------- /server/src/enum.ts: -------------------------------------------------------------------------------- 1 | export enum SOCKET { 2 | TWITCH = 'UPDATE_TWITCH', 3 | PLAYER = 'EVENT_PLAYER' 4 | } 5 | 6 | export enum PERMISSIONS { 7 | BANNED = 'ban', 8 | ALL_ACCESS = 'all-access' 9 | } 10 | -------------------------------------------------------------------------------- /client/src/enums/accessLevels.ts: -------------------------------------------------------------------------------- 1 | import { AccessLevel } from '../types' 2 | 3 | const accessLevel: AccessLevel[] = [ 4 | 'ALL', 5 | 'SUB', 6 | 'VIP', 7 | 'MOD' 8 | ] 9 | 10 | export default accessLevel 11 | -------------------------------------------------------------------------------- /client/src/config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from './types' 2 | 3 | const { hostname, protocol } = window.location 4 | const socketPort = 8081 5 | 6 | const config: Config = { 7 | socketUrl: `${protocol}//${hostname}:${socketPort}/socket` 8 | } 9 | 10 | export default config 11 | -------------------------------------------------------------------------------- /script/setup.bat: -------------------------------------------------------------------------------- 1 | cd ../client 2 | call npm install 3 | cd ../server 4 | call npm install 5 | IF NOT EXIST ".env" ( 6 | echo F | xcopy .env.example .env 7 | echo ====================== 8 | echo .env file was created. 9 | echo ====================== 10 | ) 11 | npm run production 12 | @pause -------------------------------------------------------------------------------- /client/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import useRouter from './useRouter' 2 | import { useSounds } from './useSounds' 3 | import { useAppContext } from './useAppContext' 4 | import { useUsers } from './useUsers' 5 | 6 | export { 7 | useRouter, 8 | useSounds, 9 | useAppContext, 10 | useUsers 11 | } 12 | -------------------------------------------------------------------------------- /client/src/components/dashboard/DashboardMain.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BrowserRouter } from 'react-router-dom' 3 | import { Navigation } from '.' 4 | 5 | export default () => { 6 | return ( 7 | 8 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Twitch-Play-Sounds 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /client/src/hooks/useRouter.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { __RouterContext, RouteComponentProps } from 'react-router' 3 | 4 | export default (): RouteComponentProps => ( 5 | useContext(__RouterContext) as RouteComponentProps 6 | ) 7 | -------------------------------------------------------------------------------- /client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "jubic-typescript", 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "rules": { 7 | "camelcase": [0, {"properties": "never"}], 8 | }, 9 | "globals": { 10 | "fetch": false, 11 | "FormData": false, 12 | "Audio": false 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-proposal-class-properties", 9 | "@babel/plugin-proposal-object-rest-spread", 10 | "@babel/plugin-syntax-dynamic-import" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": [ "esnext", "dom", "dom.iterable" ], 6 | "jsx": "preserve", 7 | "noEmit": true, 8 | "downlevelIteration": true, 9 | "strict": true, 10 | "allowSyntheticDefaultImports": true, 11 | "esModuleInterop": true, 12 | }, 13 | "include": [ 14 | "./src/**/*.ts", 15 | "./src/**/*.tsx" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /server/src/socket.ts: -------------------------------------------------------------------------------- 1 | import * as SocketIO from 'socket.io' 2 | 3 | import { SOCKET } from './enum' 4 | 5 | import config from './config' 6 | 7 | const io = SocketIO().listen(config.socketIoPort) 8 | 9 | export const socketPath = '/socket' 10 | 11 | io.on('connection', () => { 12 | io.of(socketPath).emit('connected') 13 | }) 14 | 15 | export const dispatchSocket = ( 16 | method: SOCKET, 17 | data: any 18 | ) => { 19 | io.of(socketPath).emit(method, data) 20 | } 21 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { "*": ["types/*"] }, 4 | "target": "es2015", 5 | "module": "commonJS", 6 | "noImplicitAny": false, 7 | "sourceMap": true, 8 | "strict": true, 9 | "allowJs": true, 10 | "lib": [ 11 | "es2016", 12 | "es2015" 13 | ], 14 | "baseUrl": "./src/", 15 | "outDir": "./dist/" 16 | }, 17 | "include": [ 18 | "src/**/**/*.ts" 19 | ], 20 | "exclude": [ 21 | "node_modules" 22 | ] 23 | } -------------------------------------------------------------------------------- /client/src/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core/styles' 2 | 3 | import palette, { customColors } from './palette' 4 | import typography from './typography' 5 | import overrides from './overrides' 6 | 7 | const theme = createMuiTheme({ 8 | palette, 9 | typography, 10 | overrides, 11 | zIndex: { 12 | appBar: 1200, 13 | drawer: 1100, 14 | modal: 1300, 15 | snackbar: 1250 16 | } 17 | }) 18 | 19 | export default theme 20 | 21 | export { 22 | customColors 23 | } 24 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { AppContainer } from 'react-hot-loader' 4 | 5 | import App from './components/App' 6 | 7 | const mount = document.getElementById('mount') 8 | const render = () => { 9 | if (!mount) { 10 | console.error('No mountpoint found!') 11 | return 12 | } 13 | 14 | ReactDOM.render(, mount) 15 | } 16 | 17 | render() 18 | 19 | if (module.hot) { 20 | module.hot.accept('./components/App', render) 21 | } 22 | -------------------------------------------------------------------------------- /server/src/config.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | 3 | dotenv.config() 4 | 5 | interface ConfigType { 6 | port: number, 7 | uuidNameSpace: string, 8 | environment: string, 9 | socketIoPort: number 10 | } 11 | 12 | const config: ConfigType = { 13 | port: Number(process.env.SERVER_PORT) || 8080, 14 | socketIoPort: Number(process.env.SOCKET_PORT) || 8081, 15 | uuidNameSpace: process.env.UUID_NAMESPACE || '00 02 32 ef 70 30 a8 ea e4 c5 21 69 95 da 8a c5', 16 | environment: process.env.ENVIRONMENT || 'PRODUCTION' 17 | } 18 | 19 | export default config 20 | -------------------------------------------------------------------------------- /client/src/index.less: -------------------------------------------------------------------------------- 1 | .contains-inv-actions{ 2 | .inv-actions{ 3 | transition: opacity 75ms; 4 | opacity: 0; 5 | display: flex; 6 | } 7 | 8 | &:hover .inv-actions{ 9 | opacity: 1; 10 | } 11 | } 12 | 13 | 14 | .icon-wrapper{ 15 | color: white; 16 | padding: 3px; 17 | border-radius: 6px; 18 | margin-top: 4px; 19 | &.ALL { 20 | background-color: #b1b1b1 21 | } 22 | 23 | &.MOD { 24 | background-color: #3cbd00 25 | } 26 | 27 | &.SUB { 28 | background-color: #0e00d4 29 | } 30 | 31 | &.VIP { 32 | background-color: #b60070 33 | } 34 | } -------------------------------------------------------------------------------- /server/src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | 3 | export const getFileExtension = (filename: string) => { 4 | return filename.split('.').pop() 5 | } 6 | 7 | export const deleteFile = ( 8 | path: string 9 | ): Promise => { 10 | return new Promise((resolve, reject) => { 11 | try { 12 | resolve(fs.unlinkSync(path)) 13 | } 14 | catch (err) { 15 | console.log(err) 16 | reject(Error) 17 | } 18 | }) 19 | } 20 | 21 | export const renameFile = ( 22 | oldPath: string, 23 | newPath: string 24 | ): Promise => { 25 | return new Promise((resolve, reject) => { 26 | fs.rename(`${oldPath}`, `${newPath}`, function (err) { 27 | if (err) reject(err) 28 | resolve() 29 | }) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /server/src/types.ts: -------------------------------------------------------------------------------- 1 | export type AccessLevel = 'ALL' | 'VIP' | 'MOD' | 'SUB' 2 | export type UserFlags = 'ban' | 'all-access' 3 | 4 | export interface Sound { 5 | id: string, 6 | access: AccessLevel, 7 | command: string, 8 | path: string, 9 | level: number 10 | } 11 | 12 | export interface SoundRequest { 13 | access: AccessLevel, 14 | command: string, 15 | path: string, 16 | level: number 17 | } 18 | 19 | export interface TwitchConfig { 20 | username: string | null, 21 | oauth: string | null, 22 | channels: string[] | null 23 | } 24 | 25 | export interface User { 26 | id: string, 27 | username: string, 28 | flags: UserFlags[] 29 | } 30 | 31 | export interface UserRequest { 32 | username: string, 33 | flags: UserFlags[] 34 | } 35 | -------------------------------------------------------------------------------- /client/src/components/player/PlayerMain.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import openSocket from 'socket.io-client' 3 | import config from '../../config' 4 | import { Sound } from '../../types' 5 | 6 | export default () => { 7 | const _playSound = (sound: Sound) => { 8 | const fixed = `/${sound.path}` 9 | const audio = new Audio(fixed) 10 | const volumeLevel = (sound.level / 100) 11 | audio.volume = 0.75 * volumeLevel 12 | audio.play() 13 | } 14 | 15 | const subscribeToSocket = () => { 16 | const socket = openSocket(config.socketUrl) 17 | 18 | socket.on('EVENT_PLAYER', (sound: Sound) => _playSound(sound)) 19 | } 20 | 21 | useEffect(() => { 22 | subscribeToSocket() 23 | }, []) 24 | 25 | return ( 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /client/src/components/dashboard/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { 4 | Typography as T, 5 | Paper, 6 | makeStyles, 7 | createStyles, 8 | Theme 9 | } from '@material-ui/core' 10 | 11 | const useStyles = makeStyles((theme: Theme) => 12 | createStyles({ 13 | sectionHeader: { 14 | margin: theme.spacing(1, 0, 0.5) 15 | }, 16 | paper: { 17 | padding: theme.spacing(2), 18 | marginBottom: theme.spacing(2) 19 | } 20 | }) 21 | ) 22 | 23 | interface Props { 24 | title: string, 25 | children: JSX.Element | JSX.Element[] 26 | } 27 | 28 | export default ({ title, children }: Props) => { 29 | const classes = useStyles() 30 | 31 | return ( 32 | 33 | {title} 34 | {children} 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /client/src/components/dashboard/ConnectedCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { TwitchUser } from '../../types' 3 | import { 4 | Typography as T, 5 | makeStyles, 6 | createStyles, 7 | Theme, 8 | Button 9 | } from '@material-ui/core' 10 | 11 | const useStyles = makeStyles((theme: Theme) => 12 | createStyles({ 13 | text: { 14 | marginTop: theme.spacing(2) 15 | } 16 | }) 17 | ) 18 | 19 | interface Props { 20 | user: TwitchUser, 21 | onLogOut: () => void 22 | } 23 | 24 | export default ({ 25 | user, 26 | onLogOut 27 | }: Props) => { 28 | const classes = useStyles() 29 | 30 | return ( 31 | <> 32 | {user.username} 33 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /client/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom' 3 | import { hot } from 'react-hot-loader' 4 | import { CssBaseline, MuiThemeProvider } from '@material-ui/core' 5 | import { AppProvider } from '../hooks/useAppContext' 6 | 7 | import theme from '../theme' 8 | import '../index.less' 9 | 10 | import { DashboardMain } from './dashboard' 11 | import { PlayerMain } from './player' 12 | 13 | const App = () => ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | } /> 21 | } /> 22 | 23 | 24 | 25 | 26 | ) 27 | 28 | export default hot(module)(App) 29 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express' 2 | import * as path from 'path' 3 | 4 | import { 5 | Request, 6 | Response, 7 | Application 8 | } from 'express' 9 | 10 | import EndPoints from './endpoints' 11 | import config from './config' 12 | import { migrateSoundDatabase } from './datastore' 13 | import { migrateUserDatabase } from './users' 14 | import twitchConnection from './twitch' 15 | 16 | const app: Application = express() 17 | 18 | app.use(express.json()) 19 | app.use(express.static('../client/build')) 20 | 21 | app.use('/api', EndPoints) 22 | 23 | app.use('/data', express.static(path.join(__dirname, '../data'))) 24 | 25 | app.get('*', (req: Request, res: Response) => { 26 | res.sendFile(path.resolve('..', 'client', 'build', 'index.html')) 27 | }) 28 | 29 | app.listen(config.port, async () => { 30 | await migrateSoundDatabase() 31 | await migrateUserDatabase() 32 | await twitchConnection.onStartConnect() 33 | console.log(`Server listening on port ${config.port}`) 34 | }) 35 | -------------------------------------------------------------------------------- /server/src/soundupload.ts: -------------------------------------------------------------------------------- 1 | import * as multer from 'multer' 2 | import * as uuid from 'uuid/v5' 3 | 4 | import config from './config' 5 | 6 | import { Request } from 'express' 7 | import { getFileExtension } from './utils' 8 | 9 | const soundStorage = multer.diskStorage({ 10 | destination: function (req, file, cb) { 11 | cb(null, './data/sounds/') 12 | }, 13 | filename: function (req: Request, file, cb) { 14 | const command = req.body.command 15 | const filename = uuid(command, config.uuidNameSpace) 16 | switch (getFileExtension(file.originalname)) { 17 | case 'mp3': 18 | cb(null, `${filename}.mp3`) 19 | break 20 | default: 21 | cb(null, `${filename}`) 22 | } 23 | } 24 | }) 25 | 26 | const soundFilter = (req, file, cb) => { 27 | if (file.mimetype === 'audio/mpeg' || file.mimetype === 'audio/mp3') { 28 | cb(null, true) 29 | } 30 | else { 31 | cb(null, false) 32 | } 33 | } 34 | 35 | export default multer({ 36 | storage: soundStorage, 37 | fileFilter: soundFilter, 38 | limits: { fileSize: 1024 * 1024 * 5 } 39 | }) 40 | -------------------------------------------------------------------------------- /client/src/components/dashboard/index.ts: -------------------------------------------------------------------------------- 1 | import DashboardMain from './DashboardMain' 2 | import Navigation from './Navigation' 3 | import HomePage from './HomePage' 4 | import SoundPage from './SoundPage' 5 | import Layout from './Layout' 6 | import SoundTable from './SoundTable' 7 | import IconHelper from './IconHelper' 8 | import ConnectionPage from './ConnectionPage' 9 | import ConnectionForm from './ConnectionForm' 10 | import ConnectedCard from './ConnectedCard' 11 | import NewSoundDialog from './dialogs/NewSoundDialog' 12 | import EditSoundDialog from './dialogs/EditSoundDialog' 13 | import UserPage from './UserPage' 14 | import UserTable from './UserTable' 15 | import NewUserDialog from './dialogs/NewUserDialog' 16 | import EditUserDialog from './dialogs/EditUserDialog' 17 | 18 | export { 19 | Navigation, 20 | DashboardMain, 21 | HomePage, 22 | SoundPage, 23 | Layout, 24 | SoundTable, 25 | NewSoundDialog, 26 | IconHelper, 27 | ConnectionPage, 28 | ConnectionForm, 29 | ConnectedCard, 30 | EditSoundDialog, 31 | UserPage, 32 | UserTable, 33 | NewUserDialog, 34 | EditUserDialog 35 | } 36 | -------------------------------------------------------------------------------- /client/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type AccessLevel = 'ALL' | 'VIP' | 'MOD' | 'SUB' 2 | export type UserFlags = 'ban' | 'all-access' 3 | 4 | export interface Sound { 5 | id: string, 6 | access: AccessLevel, 7 | command: string, 8 | path: string, 9 | level: number 10 | } 11 | 12 | export interface NewSound { 13 | access: AccessLevel, 14 | command: string, 15 | file: File, 16 | level: number 17 | } 18 | 19 | export interface NewSoundNoUpload { 20 | access: AccessLevel, 21 | command: string, 22 | level: number 23 | } 24 | 25 | export interface EditSound { 26 | id: string, 27 | access: AccessLevel, 28 | command: string, 29 | file: File | null, 30 | path: string, 31 | level: number 32 | } 33 | 34 | export interface TwitchUser { 35 | username: string | null, 36 | oauth: string | null, 37 | channels: string[] | null 38 | } 39 | 40 | export interface User { 41 | id: string, 42 | username: string, 43 | flags: UserFlags[] 44 | } 45 | 46 | export interface NewUser { 47 | username: string, 48 | flags: UserFlags[] 49 | } 50 | 51 | export interface Config { 52 | socketUrl: string 53 | } 54 | -------------------------------------------------------------------------------- /client/src/theme/palette.ts: -------------------------------------------------------------------------------- 1 | const white = '#fff' 2 | export const customColors = { 3 | danger: { 4 | contrastText: white, 5 | main: '#ED4740', 6 | light: '#FEF6F6', 7 | dark: '#BF0E08' 8 | }, 9 | warning: { 10 | contrastText: white, 11 | main: '#FFB822', 12 | light: '#FDF8F3', 13 | dark: '#95591E' 14 | }, 15 | success: { 16 | contrastText: white, 17 | main: '#00aa45', 18 | light: '#00C851', 19 | dark: '#007E33' 20 | }, 21 | info: { 22 | contrastText: white, 23 | main: '#1070CA', 24 | light: '#F1FBFC', 25 | dark: '#007489' 26 | } 27 | } 28 | 29 | export default { 30 | common: { 31 | white, 32 | black: '#000' 33 | }, 34 | primary: { 35 | contrastText: white, 36 | main: '#5e35b1', 37 | light: '#7e5dc0', 38 | dark: '#41257b' 39 | }, 40 | secondary: { 41 | contrastText: white, 42 | main: '#3949ab', 43 | light: '#606dbb', 44 | dark: '#273377' 45 | }, 46 | text: { 47 | primary: '#12161B', 48 | secondary: '#66788A', 49 | disabled: '#A6B1BB' 50 | }, 51 | background: { 52 | paper: '#fff', 53 | default: '#f8fafc' 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitch Play Sounds 2 | 3 | Twitch play sounds is a twitch-chatbot, that allows streamer to easily add and remove commands which plays certain sound effect to stream. 4 | 5 | ## Requirements: 6 | 7 | Project local json as database so no external database is needed. 8 | 9 | * Node.JS (tested on LTS 10.16.0) 10 | * NPM 11 | 12 | ## Setup: 13 | 14 | 1. Clone this repo. 15 | 2. Run `npm intall` in both `/client` and `/server`. 16 | 3. Rename `.env.example` to `.env` and change settings on your needs. 17 | 4. Run `npm run production` in `/server`. 18 | * When running second time, only `npm run server` is needed. 19 | 20 | ## Usage: 21 | 22 | Application opens (by default) to port 8080 so by going to `http://localhost:8080` you can access dashboard. After you get access to dashboard, go to `connections` tab and connect your twitch user into dashboard. When it's successfully connected, go to sounds manager and start adding sound effects. NOTE that only `.mp3` files are currently supported! After you have added command, open `http://localhost:8080/player` as tab or in OBS Browser. 23 | 24 | ## Images: 25 | 26 | ![Sound Manager](https://i.imgur.com/0ByIWs8.png) 27 | ![Connections](https://i.imgur.com/36DMmSL.png) 28 | ![Home](https://i.imgur.com/fJD9RjM.png) -------------------------------------------------------------------------------- /client/src/components/dashboard/IconHelper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { AccessLevel } from '../../types' 3 | import { Twitch, Sword, Crown, StarFourPoints } from 'mdi-material-ui' 4 | import { Tooltip } from '@material-ui/core' 5 | 6 | interface Props { 7 | access: AccessLevel 8 | } 9 | 10 | export default ({ 11 | access 12 | }: Props) => { 13 | switch (access) { 14 | case 'ALL': return ( 15 | 16 | 17 | 18 | ) 19 | case 'SUB': return ( 20 | 21 | 22 | 23 | ) 24 | case 'MOD': return ( 25 | 26 | 27 | 28 | ) 29 | case 'VIP': return ( 30 | 31 | 32 | 33 | ) 34 | default: return ( 35 | 36 | 37 | 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/hooks/useUsers.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { User, NewUser } from '../types' 3 | 4 | import * as client from '../client' 5 | 6 | interface UserContext { 7 | users: User[], 8 | loading: boolean, 9 | addUser: (set: NewUser) => Promise, 10 | editUser: (set: User) => Promise, 11 | deleteUser: (id: string) => Promise 12 | } 13 | 14 | export const useUsers = (): UserContext => { 15 | const [users, setUsers] = useState([]) 16 | const [loading, setLoading] = useState(true) 17 | 18 | const getUsers = () => client.fetchUsers() 19 | .then(users => { 20 | setUsers(users) 21 | setLoading(false) 22 | }) 23 | 24 | const addUser = ( 25 | user: NewUser 26 | ): Promise => client.addUsers(user) 27 | .then(s => setUsers([ ...users, s ])) 28 | 29 | const deleteUser = (id: string) => client.deleteUsers(id) 30 | .then(users => setUsers(users)) 31 | 32 | const editUser = (user: User) => client.editUser(user.id, user) 33 | .then(u => setUsers(users.map(i => i.id === u.id ? u : i))) 34 | 35 | useEffect(() => { 36 | getUsers() 37 | }, []) 38 | 39 | return { 40 | users, 41 | loading, 42 | addUser, 43 | editUser, 44 | deleteUser 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/src/components/dashboard/ConnectionPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Layout, ConnectionForm, ConnectedCard } from '.' 3 | import { customColors } from '../../theme' 4 | import clsx from 'clsx' 5 | import { useAppContext } from '../../hooks' 6 | 7 | import { 8 | createStyles, 9 | makeStyles, 10 | Theme, 11 | Chip 12 | } from '@material-ui/core' 13 | 14 | const useStyles = makeStyles((theme: Theme) => 15 | createStyles({ 16 | chip: { 17 | marginTop: theme.spacing(1), 18 | color: theme.palette.common.white 19 | }, 20 | connected: { 21 | backgroundColor: customColors.success.main 22 | }, 23 | disconnected: { 24 | backgroundColor: customColors.danger.main 25 | }, 26 | loading: { 27 | marginTop: theme.spacing(1) 28 | } 29 | }) 30 | ) 31 | 32 | export default () => { 33 | const classes = useStyles() 34 | const { user, auth, login, logout } = useAppContext() 35 | 36 | return ( 37 | <> 38 | 39 | 46 | 47 | 48 | {!auth || !user ? ( 49 | 50 | ) : ( 51 | 52 | )} 53 | 54 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /client/src/theme/typography.ts: -------------------------------------------------------------------------------- 1 | import palette from './palette' 2 | import { TypographyOptions } from '@material-ui/core/styles/createTypography' 3 | 4 | const typography: TypographyOptions = { 5 | h1: { 6 | fontWeight: 500, 7 | fontSize: '35px', 8 | letterSpacing: '-0.24px', 9 | lineHeight: '40px' 10 | }, 11 | h2: { 12 | fontWeight: 500, 13 | fontSize: '29px', 14 | letterSpacing: '-0.24px', 15 | lineHeight: '32px' 16 | }, 17 | h3: { 18 | fontWeight: 500, 19 | fontSize: '24px', 20 | letterSpacing: '-0.06px', 21 | lineHeight: '28px' 22 | }, 23 | h4: { 24 | fontWeight: 500, 25 | fontSize: '20px', 26 | letterSpacing: '-0.06px', 27 | lineHeight: '24px' 28 | }, 29 | h5: { 30 | fontWeight: 500, 31 | fontSize: '16px', 32 | letterSpacing: '-0.05px', 33 | lineHeight: '20px' 34 | }, 35 | h6: { 36 | fontWeight: 500, 37 | fontSize: '14px', 38 | letterSpacing: '-0.05px', 39 | lineHeight: '20px' 40 | }, 41 | subtitle1: { 42 | fontSize: '16px', 43 | letterSpacing: '-0.05px', 44 | lineHeight: '25px' 45 | }, 46 | subtitle2: { 47 | fontSize: '14px', 48 | letterSpacing: 0, 49 | lineHeight: '16px' 50 | }, 51 | body1: { 52 | color: palette.text.primary, 53 | fontSize: '14px', 54 | letterSpacing: '-0.05px', 55 | lineHeight: '21px' 56 | }, 57 | body2: { 58 | color: palette.text.primary, 59 | fontSize: '12px', 60 | letterSpacing: '-0.04px', 61 | lineHeight: '14px' 62 | }, 63 | button: { 64 | fontSize: '14px' 65 | }, 66 | caption: { 67 | color: palette.text.secondary, 68 | fontSize: '12px', 69 | letterSpacing: '0.3px', 70 | lineHeight: '16px' 71 | } 72 | } 73 | 74 | export default typography 75 | -------------------------------------------------------------------------------- /client/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | const CleanWebpackPlugin = require('clean-webpack-plugin') 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 5 | const CopyWebpackPlugin = require('copy-webpack-plugin') 6 | 7 | module.exports = { 8 | mode: 'production', 9 | entry: [ 10 | './src/index.tsx' 11 | ], 12 | resolve: { 13 | extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'] 14 | }, 15 | output: { 16 | filename: 'bundle.[hash].js', 17 | path: path.resolve(__dirname, 'build') 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | exclude: /node_modules/, 23 | test: /\.[j|t]sx?$/, 24 | loader: 'babel-loader', 25 | options: { 26 | compact: true 27 | } 28 | }, { 29 | test: /\.(woff|woff2|eot|ttf|otf|svg|png)$/, 30 | loader: 'file-loader' 31 | }, { 32 | test: /\.css$/, 33 | use: [ 34 | 'style-loader', 35 | 'css-loader' 36 | ] 37 | }, { 38 | test: /\.less$/, 39 | use: [ 40 | 'style-loader', 41 | 'css-loader', 42 | 'less-loader', 43 | 'import-glob' 44 | ] 45 | } 46 | ] 47 | }, 48 | plugins: [ 49 | new CleanWebpackPlugin({ 50 | cleanOnceBeforeBuildPatterns: ['build/*'] 51 | }), 52 | new HtmlWebpackPlugin({ 53 | inject: true, 54 | hash: true, 55 | template: './public/index.html', 56 | filename: 'index.html' 57 | }), 58 | new MiniCssExtractPlugin({ 59 | filename: 'styles.[hash].css' 60 | }), 61 | new CopyWebpackPlugin([{ 62 | from: path.resolve(__dirname, 'public'), 63 | to: path.resolve(__dirname, 'build') 64 | }]) 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /client/src/components/dashboard/UserPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Layout, UserTable, NewUserDialog, EditUserDialog } from '.' 3 | import { User } from '../../types' 4 | import { Plus } from 'mdi-material-ui' 5 | import { useUsers } from '../../hooks' 6 | 7 | import { 8 | createStyles, 9 | Theme, 10 | makeStyles, 11 | Tooltip, 12 | Fab 13 | } from '@material-ui/core' 14 | 15 | const useStyles = makeStyles((theme: Theme) => 16 | createStyles({ 17 | action: { 18 | position: 'fixed', 19 | bottom: '0', 20 | right: '0', 21 | margin: theme.spacing(3.5) 22 | } 23 | }) 24 | ) 25 | 26 | export default () => { 27 | const classes = useStyles() 28 | const [ isNewDialogOpen, setIsNewDialogOpen ] = useState(false) 29 | const [ editableUser, setEditableUser ] = useState(null) 30 | const { 31 | users, 32 | loading, 33 | addUser, 34 | deleteUser, 35 | editUser 36 | } = useUsers() 37 | 38 | return ( 39 | <> 40 | 41 | setEditableUser(user)} 46 | /> 47 | 48 |
49 | 50 | setIsNewDialogOpen(true)} 53 | > 54 | 55 | 56 | 57 |
58 | setIsNewDialogOpen(false)} 61 | onAdd={addUser} 62 | /> 63 | setEditableUser(null)} 66 | user={editableUser} 67 | onEdit={editUser} 68 | setUser={setEditableUser} 69 | /> 70 | 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /client/src/hooks/useAppContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useContext, 3 | useEffect, 4 | useState, 5 | ReactNode, 6 | createContext 7 | } from 'react' 8 | 9 | import * as client from '../client' 10 | import openSocket from 'socket.io-client' 11 | import { TwitchUser } from '../types' 12 | import config from '../config' 13 | 14 | interface Context { 15 | user: null | TwitchUser, 16 | update: () => void, 17 | logout: () => void, 18 | login: (user: TwitchUser) => void, 19 | auth: boolean 20 | } 21 | 22 | export const __AppContext = createContext({ 23 | user: null, 24 | update: () => {}, 25 | logout: () => {}, 26 | login: () => {}, 27 | auth: false 28 | }) 29 | 30 | interface Props { 31 | children: ReactNode 32 | } 33 | 34 | export const AppProvider = ({ children }: Props) => { 35 | const [user, setUser] = useState(null) 36 | const [auth, setAuth] = useState(false) 37 | 38 | const subscribeToSocket = () => { 39 | const socket = openSocket(config.socketUrl) 40 | 41 | socket.on('UPDATE_TWITCH', ( 42 | auth: boolean 43 | ) => setAuth(auth)) 44 | } 45 | 46 | const checkAuth = () => client.checkAuth() 47 | .then(setAuth) 48 | 49 | const update = () => { 50 | client.fetchUser() 51 | .then(user => setUser(user)) 52 | subscribeToSocket() 53 | checkAuth() 54 | } 55 | 56 | const login = (newUser: TwitchUser) => { 57 | client.addUser(newUser) 58 | .then(() => { 59 | setUser(newUser) 60 | setAuth(true) 61 | }) 62 | } 63 | 64 | const logout = () => client.logOut() 65 | .then(() => setUser(null)) 66 | 67 | useEffect(update, []) 68 | 69 | return ( 70 | <__AppContext.Provider 71 | value={{ 72 | user, 73 | update, 74 | login, 75 | logout, 76 | auth 77 | }}> 78 | {children} 79 | 80 | ) 81 | } 82 | 83 | export const useAppContext = (): Context => useContext(__AppContext) 84 | -------------------------------------------------------------------------------- /client/src/components/dashboard/SoundPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useSounds } from '../../hooks/useSounds' 3 | import { EditSound } from '../../types' 4 | 5 | import { 6 | Layout, 7 | SoundTable, 8 | NewSoundDialog, 9 | EditSoundDialog 10 | } from '.' 11 | 12 | import { 13 | Tooltip, 14 | Fab, 15 | createStyles, 16 | Theme, 17 | makeStyles 18 | } from '@material-ui/core' 19 | 20 | import { Plus } from 'mdi-material-ui' 21 | 22 | const useStyles = makeStyles((theme: Theme) => 23 | createStyles({ 24 | action: { 25 | position: 'fixed', 26 | bottom: '0', 27 | right: '0', 28 | margin: theme.spacing(3.5) 29 | } 30 | }) 31 | ) 32 | 33 | export default () => { 34 | const classes = useStyles() 35 | const { 36 | sounds, 37 | loading, 38 | addSound, 39 | editSound, 40 | deleteSound 41 | } = useSounds() 42 | 43 | const [ isNewDialogOpen, setIsNewDialogOpen ] = useState(false) 44 | const [ editableSound, setEditableSound ] = useState(null) 45 | 46 | return ( 47 | 48 | 54 |
55 | 56 | setIsNewDialogOpen(true)} 59 | > 60 | 61 | 62 | 63 |
64 | setIsNewDialogOpen(false)} 67 | onAdd={addSound} 68 | /> 69 | setEditableSound(null)} 72 | sound={editableSound} 73 | onEdit={editSound} 74 | setSound={setEditableSound} 75 | /> 76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitch-play-sound-server", 3 | "version": "1.0.0", 4 | "description": "twitch-play-sound-server", 5 | "author": "Jesse Båtman (http://jessebatman.fi)", 6 | "main": "src/server.ts", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/Morabotti/twitch-play-sound.git" 10 | }, 11 | "scripts": { 12 | "start": "node --inspect=5858 -r ts-node/register ./src/index.ts", 13 | "dev": "nodemon", 14 | "build": "tsc", 15 | "build:start": "tsc && node dist/index.js", 16 | "production": "cd ../client & npm run build && cd ../server & npm run build:start", 17 | "server": "node dist/index.js", 18 | "lint": "eslint \"src/**/*.{ts,tsx}\"", 19 | "format": "eslint \"src/**/*.{ts,tsx}\" --fix" 20 | }, 21 | "nodemonConfig": { 22 | "ignore": [ 23 | "**/*.test.ts", 24 | "**/*.spec.ts", 25 | ".git", 26 | "node_modules" 27 | ], 28 | "watch": [ 29 | "src" 30 | ], 31 | "exec": "npm start", 32 | "ext": "ts" 33 | }, 34 | "license": "MIT", 35 | "dependencies": { 36 | "body-parser": "^1.18.3", 37 | "dotenv": "^7.0.0", 38 | "eslint": "5.16.0", 39 | "eslint-config-jubic-typescript": "1.0.0", 40 | "eslint-plugin-import": "^2.18.0", 41 | "eslint-plugin-node": "^9.1.0", 42 | "express": "^4.16.4", 43 | "jsonfile": "^5.0.0", 44 | "moment": "^2.24.0", 45 | "multer": "^1.4.1", 46 | "socket.io": "^2.2.0", 47 | "tmi.js": "^1.4.5", 48 | "ts-node": "^8.0.3", 49 | "typescript": "^3.3.3333", 50 | "uuid": "^3.3.2" 51 | }, 52 | "devDependencies": { 53 | "@types/body-parser": "^1.17.0", 54 | "@types/dotenv": "^6.1.0", 55 | "@types/express": "^4.16.1", 56 | "@types/jsonfile": "^5.0.0", 57 | "@types/moment": "^2.13.0", 58 | "@types/mongoose": "^5.3.26", 59 | "@types/multer": "^1.3.7", 60 | "@types/node": "^11.11.3", 61 | "@types/socket.io": "^2.1.2", 62 | "@types/tmi.js": "^1.4.0", 63 | "@types/uuid": "^3.4.5", 64 | "csgo-gsi-types": "^1.0.5", 65 | "nodemon": "^1.18.9", 66 | "ts-essentials": "^2.0.7" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /client/src/hooks/useSounds.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Sound, NewSound, EditSound } from '../types' 3 | 4 | import * as client from '../client' 5 | 6 | interface SoundContext { 7 | sounds: Sound[], 8 | loading: boolean, 9 | addSound: (sound: NewSound) => Promise, 10 | editSound: (sound: EditSound) => Promise, 11 | deleteSound: (id: string) => void 12 | } 13 | 14 | export const useSounds = (): SoundContext => { 15 | const [sounds, setSounds] = useState([]) 16 | const [loading, setLoading] = useState(true) 17 | 18 | const getSounds = () => client.fetchSounds() 19 | .then(sounds => { 20 | setSounds(sounds) 21 | setLoading(false) 22 | }) 23 | 24 | const addSound = (sound: NewSound): Promise => { 25 | const form = new FormData() 26 | form.append('access', sound.access) 27 | form.append('command', sound.command) 28 | form.append('sound', sound.file) 29 | form.append('level', sound.level.toString()) 30 | return client.addSound(form) 31 | .then(s => setSounds([...sounds, s])) 32 | } 33 | 34 | const deleteSound = (id: string) => { 35 | client.deleteSound(id) 36 | .then(sounds => setSounds(sounds)) 37 | } 38 | 39 | const editSound = (sound: EditSound) => { 40 | if (sound.file) { 41 | const form = new FormData() 42 | form.append('access', sound.access) 43 | form.append('command', sound.command) 44 | form.append('level', sound.level.toString()) 45 | form.append('sound', sound.file) 46 | return client.editSound(sound.id, form) 47 | .then(s => setSounds(sounds.map(i => i.id === s.id ? s : i))) 48 | } 49 | else { 50 | return client.editSoundNoUpload(sound.id, { 51 | access: sound.access, 52 | command: sound.command, 53 | level: sound.level 54 | }) 55 | .then(s => setSounds(sounds.map(i => i.id === s.id ? s : i))) 56 | } 57 | } 58 | 59 | useEffect(() => { 60 | getSounds() 61 | }, []) 62 | 63 | return { 64 | sounds, 65 | loading, 66 | addSound, 67 | editSound, 68 | deleteSound 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /client/src/components/dashboard/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Layout } from '.' 3 | import { Refresh } from 'mdi-material-ui' 4 | 5 | import { 6 | createStyles, 7 | Theme, 8 | makeStyles, 9 | Button, 10 | TextField 11 | } from '@material-ui/core' 12 | 13 | const useStyles = makeStyles((theme: Theme) => 14 | createStyles({ 15 | button: { 16 | margin: theme.spacing(1, 2, 0, 0) 17 | }, 18 | rightIcon: { 19 | marginLeft: theme.spacing(1) 20 | }, 21 | actionContainer: { 22 | marginBottom: theme.spacing(2) 23 | }, 24 | url: { 25 | textDecoration: 'none' 26 | } 27 | }) 28 | ) 29 | 30 | export default () => { 31 | const [isDisabled, setDisabled] = useState(false) 32 | const classes = useStyles() 33 | 34 | return ( 35 | <> 36 | 37 |
...
38 |
39 | 40 |
41 | 49 | 53 | 58 | 65 | 66 |
67 | 78 |
79 | 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /client/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const webpack = require('webpack') 4 | const HtmlWebpackPlugin = require('html-webpack-plugin') 5 | const CopyWebpackPlugin = require('copy-webpack-plugin') 6 | 7 | const directory = fs.realpathSync(process.cwd()) 8 | const resolve = (relativePath) => path.resolve(directory, relativePath) 9 | 10 | module.exports = { 11 | mode: 'development', 12 | entry: { 13 | 'js': [ 14 | require.resolve('react-hot-loader/patch'), 15 | resolve('src/index.tsx') 16 | ] 17 | }, 18 | resolve: { 19 | extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'] 20 | }, 21 | output: { 22 | pathinfo: true, 23 | filename: '[name]/bundle.js', 24 | path: resolve('build'), 25 | publicPath: '/' 26 | }, 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.[j|t]sx?$/, 31 | exclude: /node_modules/, 32 | loader: require.resolve('babel-loader'), 33 | options: { 34 | cacheDirectory: true, 35 | plugins: ['react-hot-loader/babel'] 36 | } 37 | }, 38 | { 39 | test: /\.less$/, 40 | use: [ 41 | 'style-loader', 42 | 'css-loader', 43 | 'less-loader', 44 | 'import-glob' 45 | ] 46 | }, 47 | { 48 | test: /\.css$/, 49 | use: [ 50 | 'style-loader', 51 | 'css-loader' 52 | ] 53 | }, 54 | { 55 | test: /\.(woff|woff2|eot|ttf|otf|svg|png)$/, 56 | use: ['file-loader'] 57 | } 58 | ] 59 | }, 60 | plugins: [ 61 | new HtmlWebpackPlugin({ 62 | inject: true, 63 | template: resolve('./public/index.html'), 64 | chunks: ['js'] 65 | }), 66 | new webpack.HotModuleReplacementPlugin(), 67 | new CopyWebpackPlugin([{ 68 | from: path.resolve(__dirname, 'public'), 69 | to: path.resolve(__dirname, 'build') 70 | }]) 71 | ], 72 | devServer: { 73 | proxy: { 74 | '/api': { 75 | target: 'http://localhost:8080' 76 | }, 77 | '/data': { 78 | target: 'http://localhost:8080' 79 | } 80 | }, 81 | port: 8082, 82 | contentBase: resolve('./build'), 83 | hot: true, 84 | disableHostCheck: true, 85 | historyApiFallback: true 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitch-play-sound-client", 3 | "version": "1.0.0", 4 | "description": "twitch-play-sound-client", 5 | "author": "Jesse Båtman (http://jessebatman.fi)", 6 | "main": "src/index.ts", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/Morabotti/twitch-play-sound.git" 11 | }, 12 | "scripts": { 13 | "start": "webpack-dev-server --config webpack.config.dev.js", 14 | "build": "webpack --mode production --config webpack.config.js", 15 | "lint": "eslint \"src/**/*.{ts,tsx}\"", 16 | "format": "eslint \"src/**/*.{ts,tsx}\" --fix", 17 | "types": "tsc", 18 | "test": "npm run lint && npm run types" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "16.8.18", 22 | "@types/react-dom": "16.8.4", 23 | "@types/react-router-dom": "^4.3.4", 24 | "@types/socket.io-client": "^1.4.32", 25 | "@types/webpack-env": "^1.14.0", 26 | "react-hot-loader": "4.12.6", 27 | "webpack-dev-server": "3.7.2" 28 | }, 29 | "dependencies": { 30 | "@babel/core": "7.4.5", 31 | "@babel/plugin-proposal-class-properties": "7.4.4", 32 | "@babel/plugin-proposal-object-rest-spread": "7.4.4", 33 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 34 | "@babel/preset-env": "7.4.5", 35 | "@babel/preset-react": "7.0.0", 36 | "@babel/preset-typescript": "7.3.3", 37 | "@material-ui/core": "^4.2.0", 38 | "babel-loader": "8.0.6", 39 | "clean-webpack-plugin": "2.0.2", 40 | "clsx": "^1.0.4", 41 | "copy-webpack-plugin": "5.0.3", 42 | "css-loader": "2.1.0", 43 | "eslint": "5.16.0", 44 | "eslint-config-jubic-typescript": "1.0.0", 45 | "eslint-plugin-import": "^2.18.0", 46 | "eslint-plugin-node": "^9.1.0", 47 | "eslint-plugin-react": "^7.14.2", 48 | "file-loader": "3.0.1", 49 | "html-webpack-plugin": "3.2.0", 50 | "import-glob": "1.5.0", 51 | "less": "3.9.0", 52 | "less-loader": "4.1.0", 53 | "mdi-material-ui": "^6.1.0", 54 | "mini-css-extract-plugin": "0.6.0", 55 | "react": "16.8.6", 56 | "react-dom": "16.8.6", 57 | "react-router-dom": "^5.0.1", 58 | "socket.io-client": "^2.2.0", 59 | "style-loader": "0.23.1", 60 | "typescript": "3.5.3", 61 | "webpack": "4.32.1", 62 | "webpack-cli": "3.3.1", 63 | "whatwg-fetch": "^3.0.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /client/src/components/dashboard/UserTable.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import clsx from 'clsx' 3 | 4 | import { 5 | User 6 | } from '../../types' 7 | 8 | import { 9 | Table, 10 | TableHead, 11 | TableCell, 12 | TableBody, 13 | TableRow, 14 | createStyles, 15 | makeStyles, 16 | Theme, 17 | LinearProgress, 18 | IconButton, 19 | Tooltip, 20 | Typography as T 21 | } from '@material-ui/core' 22 | 23 | import { DeleteOutline, Pencil } from 'mdi-material-ui' 24 | 25 | const useStyles = makeStyles((theme: Theme) => 26 | createStyles({ 27 | table: { 28 | minWidth: 650, 29 | marginTop: theme.spacing(1) 30 | }, 31 | loading: { 32 | marginTop: theme.spacing(1) 33 | }, 34 | small: { 35 | padding: theme.spacing(0.75) 36 | }, 37 | iconOffset: { 38 | marginRight: theme.spacing(2) 39 | }, 40 | accessText: { 41 | display: 'inline-flex', 42 | verticalAlign: 'super', 43 | marginLeft: theme.spacing(1.5) 44 | } 45 | }) 46 | ) 47 | 48 | interface Props { 49 | users: User[], 50 | loading: boolean, 51 | onDelete: (id: string) => void, 52 | onEdit: (user: User) => void 53 | } 54 | 55 | export default ({ 56 | users, 57 | loading, 58 | onDelete, 59 | onEdit 60 | }: Props) => { 61 | const classes = useStyles() 62 | 63 | if (loading) { 64 | return ( 65 | 66 | ) 67 | } 68 | 69 | return ( 70 | 71 | 72 | 73 | Username 74 | Permissions 75 | Actions 76 | 77 | 78 | 79 | {users.map(user => ( 80 | 84 | 85 | {user.username} 86 | 87 | 88 | {user.flags.map(i => {i} )} 89 | 90 | 91 |
92 | 93 | onEdit(user)} 96 | > 97 | 98 | 99 | 100 | 101 | onDelete(user.id)} 104 | > 105 | 106 | 107 | 108 |
109 |
110 |
111 | ))} 112 |
113 |
114 | ) 115 | } 116 | -------------------------------------------------------------------------------- /client/src/components/dashboard/ConnectionForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { customColors } from '../../theme' 3 | import { Eye, EyeOff } from 'mdi-material-ui' 4 | import { TwitchUser } from '../../types' 5 | 6 | import { 7 | createStyles, 8 | Theme, 9 | makeStyles, 10 | Button, 11 | TextField, 12 | FormControl, 13 | InputLabel, 14 | Input, 15 | InputAdornment, 16 | IconButton, 17 | Typography as T 18 | } from '@material-ui/core' 19 | 20 | const useStyles = makeStyles((theme: Theme) => 21 | createStyles({ 22 | text: { 23 | marginTop: theme.spacing(2) 24 | }, 25 | error: { 26 | color: customColors.danger.main 27 | }, 28 | link: { 29 | textDecoration: 'none', 30 | color: theme.palette.secondary.light, 31 | fontWeight: 700 32 | } 33 | }) 34 | ) 35 | 36 | interface Props { 37 | onAuth: (user: TwitchUser) => void 38 | } 39 | 40 | interface State { 41 | username: string, 42 | oauth: string, 43 | error: boolean, 44 | showKey: boolean 45 | } 46 | 47 | const getInitialState = (): State => ({ 48 | username: '', 49 | oauth: '', 50 | error: false, 51 | showKey: false 52 | }) 53 | 54 | export default ({ 55 | onAuth 56 | }: Props) => { 57 | const [state, setState] = useState(getInitialState) 58 | const classes = useStyles() 59 | 60 | const handleSubmit = () => { 61 | onAuth({ 62 | username: state.username, 63 | oauth: state.oauth, 64 | channels: [state.username] 65 | }) 66 | setState(getInitialState) 67 | } 68 | 69 | return ( 70 | <> 71 | setState({ 75 | ...state, 76 | username: e.currentTarget.value 77 | })} 78 | fullWidth 79 | error={state.error} 80 | className={classes.text} 81 | /> 82 | 83 | OAuth key 84 | setState({ 90 | ...state, 91 | oauth: e.currentTarget.value 92 | })} 93 | endAdornment={ 94 | 95 | setState({ 98 | ...state, 99 | showKey: !state.showKey 100 | })} 101 | > 102 | {state.showKey ? : } 103 | 104 | 105 | } 106 | /> 107 | 108 | Get OAauth key: Here 111 | 112 | 121 | 122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /server/src/users.ts: -------------------------------------------------------------------------------- 1 | import * as jsonfile from 'jsonfile' 2 | import * as uuid from 'uuid/v5' 3 | 4 | import config from './config' 5 | 6 | import { User, UserRequest } from './types' 7 | 8 | const fileUsers = './db/db-users.json' 9 | 10 | const readUsers = (): Promise => new Promise((resolve, reject) => { 11 | jsonfile.readFile(fileUsers, (err, mainObj: User[]) => { 12 | if (err) reject(err) 13 | resolve(mainObj) 14 | }) 15 | }) 16 | 17 | const setUsers = ( 18 | data: User[] 19 | ) => new Promise((resolve, reject) => { 20 | jsonfile.writeFile(fileUsers, data, (err) => { 21 | if (err) reject(err) 22 | resolve() 23 | }) 24 | }) 25 | 26 | export const isUserTaken = async ( 27 | username: string 28 | ): Promise => { 29 | if (username === '') { 30 | throw new Error('Given command is empty') 31 | } 32 | 33 | const users = await readUsers() 34 | const found = users.find(s => s.username === username) 35 | return found === undefined 36 | } 37 | 38 | export const findUserById = async ( 39 | id: string 40 | ) => { 41 | if (id === '') { 42 | throw new Error('Given ID is empty') 43 | } 44 | const users = await readUsers() 45 | const found = users.find(s => s.id === id) 46 | if (!found) { 47 | throw new Error('User was not found') 48 | } 49 | return found 50 | } 51 | 52 | export const addUser = async ( 53 | user: UserRequest 54 | ): Promise => { 55 | try { 56 | const isFree = await isUserTaken(user.username) 57 | if (isFree) { 58 | const users = await readUsers() 59 | const newUser: User = { 60 | ...user, 61 | id: uuid(user.username, config.uuidNameSpace) 62 | } 63 | await setUsers([ ...users, newUser ]) 64 | 65 | return newUser 66 | } 67 | else { 68 | throw new Error('Sound command is already taken!') 69 | } 70 | } 71 | catch (err) { 72 | console.log(err) 73 | throw new Error(err) 74 | } 75 | } 76 | 77 | export const deleteUser = async ( 78 | id: string 79 | ): Promise => { 80 | try { 81 | const oldUser = await findUserById(id) 82 | const userList = await readUsers() 83 | 84 | const updated = userList.filter(l => l.id !== oldUser.id) 85 | await setUsers(updated) 86 | 87 | return updated 88 | } 89 | catch (err) { 90 | console.log(err) 91 | throw new Error(err) 92 | } 93 | } 94 | 95 | export const updateUser = async ( 96 | id: string, 97 | user: User 98 | ): Promise => { 99 | try { 100 | const oldUser = await findUserById(id) 101 | const userList = await readUsers() 102 | 103 | const isFree = await isUserTaken(user.username) 104 | 105 | if (oldUser.username !== user.username && !isFree) { 106 | throw new Error('This command is already taken') 107 | } 108 | 109 | const newUser: User = { 110 | id: oldUser.id, 111 | ...user 112 | } 113 | 114 | const updated = userList.map(l => l.id === newUser.id ? newUser : l) 115 | await setUsers(updated) 116 | 117 | return newUser 118 | } 119 | catch (err) { 120 | console.log(err) 121 | throw new Error(err) 122 | } 123 | } 124 | 125 | export const findUser = async ( 126 | username: string | undefined 127 | ): Promise => { 128 | if (username === undefined) { 129 | return null 130 | } 131 | 132 | const users = await readUsers() 133 | const found = users.find(s => s.username.toLowerCase() === username.toLowerCase()) 134 | if (!found) { 135 | return null 136 | } 137 | 138 | return found 139 | } 140 | 141 | export const fetchUsers = async (): Promise => readUsers() 142 | export const migrateUserDB = async () => setUsers([]) 143 | 144 | export const migrateUserDatabase = async () => { 145 | try { 146 | await fetchUsers() 147 | } 148 | catch (e) { 149 | console.log('Migrating user DB') 150 | await migrateUserDB() 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /client/src/components/dashboard/SoundTable.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { IconHelper } from '.' 3 | import clsx from 'clsx' 4 | 5 | import { 6 | Sound, 7 | EditSound 8 | } from '../../types' 9 | 10 | import { 11 | Table, 12 | TableHead, 13 | TableCell, 14 | TableBody, 15 | TableRow, 16 | createStyles, 17 | makeStyles, 18 | Theme, 19 | LinearProgress, 20 | IconButton, 21 | Tooltip, 22 | Typography as T 23 | } from '@material-ui/core' 24 | 25 | import { DeleteOutline, Play, Pencil } from 'mdi-material-ui' 26 | 27 | const useStyles = makeStyles((theme: Theme) => 28 | createStyles({ 29 | table: { 30 | minWidth: 650, 31 | marginTop: theme.spacing(1) 32 | }, 33 | loading: { 34 | marginTop: theme.spacing(1) 35 | }, 36 | small: { 37 | padding: theme.spacing(0.75) 38 | }, 39 | iconOffset: { 40 | marginRight: theme.spacing(2) 41 | }, 42 | accessText: { 43 | display: 'inline-flex', 44 | verticalAlign: 'super', 45 | marginLeft: theme.spacing(1.5) 46 | } 47 | }) 48 | ) 49 | 50 | interface Props { 51 | sounds: Sound[], 52 | loading: boolean, 53 | onDelete: (id: string) => void, 54 | onEdit: (sound: EditSound | null) => void 55 | } 56 | 57 | export default ({ 58 | sounds, 59 | loading, 60 | onDelete, 61 | onEdit 62 | }: Props) => { 63 | const classes = useStyles() 64 | 65 | if (loading) { 66 | return ( 67 | 68 | ) 69 | } 70 | 71 | const _playSound = (sound: Sound) => () => { 72 | const fixed = `/${sound.path}` 73 | const audio = new Audio(fixed) 74 | const volumeLevel = (sound.level / 100) 75 | audio.volume = 0.75 * volumeLevel 76 | audio.play() 77 | } 78 | 79 | return ( 80 | 81 | 82 | 83 | Command 84 | Access 85 | Actions 86 | 87 | 88 | 89 | {sounds.map(sound => ( 90 | 94 | 95 | {sound.command} 96 | 97 | 98 | 99 | {sound.access} 100 | 101 | 102 |
103 | 104 | 108 | 109 | 110 | 111 | 112 | onEdit({ 115 | ...sound, 116 | file: null 117 | })} 118 | > 119 | 120 | 121 | 122 | 123 | onDelete(sound.id)} 126 | > 127 | 128 | 129 | 130 |
131 |
132 |
133 | ))} 134 |
135 |
136 | ) 137 | } 138 | -------------------------------------------------------------------------------- /client/src/client.ts: -------------------------------------------------------------------------------- 1 | import { Sound, TwitchUser, NewSoundNoUpload, User, NewUser } from './types' 2 | 3 | const checkResponse = (res: Response): Response => { 4 | if (!res.ok) { 5 | throw Error(res.statusText) 6 | } 7 | return res 8 | } 9 | 10 | export const fetchSounds = (): Promise => fetch( 11 | '/api/sounds', 12 | { 13 | method: 'GET', 14 | headers: { 'Content-Type': 'application/json' } 15 | } 16 | ) 17 | .then(checkResponse) 18 | .then((res) => res.json()) 19 | 20 | export const checkAuth = (): Promise => fetch( 21 | '/api/twitch/auth', 22 | { 23 | method: 'GET', 24 | headers: { 'Content-Type': 'application/json' } 25 | } 26 | ) 27 | .then(checkResponse) 28 | .then((res) => res.json()) 29 | 30 | export const addSound = ( 31 | data: FormData 32 | ): Promise => { 33 | const options = { 34 | method: 'POST', 35 | body: data, 36 | headers: { 'content-type': 'multipart/form-data' } 37 | } 38 | 39 | delete options.headers['content-type'] 40 | 41 | return fetch('/api/sounds', options) 42 | .then(checkResponse) 43 | .then((res) => res.json()) 44 | } 45 | 46 | export const editSound = ( 47 | id: string, 48 | data: FormData 49 | ): Promise => { 50 | const options = { 51 | method: 'PUT', 52 | body: data, 53 | headers: { 'content-type': 'multipart/form-data' } 54 | } 55 | 56 | delete options.headers['content-type'] 57 | 58 | return fetch('/api/sounds/' + id + '/upload', options) 59 | .then(checkResponse) 60 | .then((res) => res.json()) 61 | } 62 | 63 | export const editSoundNoUpload = ( 64 | id: string, 65 | sound: NewSoundNoUpload 66 | ): Promise => fetch( 67 | '/api/sounds/' + id + '/standard', 68 | { 69 | method: 'PUT', 70 | body: JSON.stringify(sound), 71 | headers: { 'Content-Type': 'application/json' } 72 | } 73 | ) 74 | .then(checkResponse) 75 | .then((res) => res.json()) 76 | 77 | export const deleteSound = (id: string): Promise => fetch( 78 | '/api/sounds/' + id, 79 | { 80 | method: 'DELETE', 81 | headers: { 'Content-Type': 'application/json' } 82 | } 83 | ) 84 | .then(checkResponse) 85 | .then((res) => res.json()) 86 | 87 | export const fetchUser = (): Promise => fetch( 88 | '/api/twitch', 89 | { 90 | method: 'GET', 91 | headers: { 'Content-Type': 'application/json' } 92 | } 93 | ) 94 | .then(checkResponse) 95 | .then((res) => res.json()) 96 | 97 | export const fetchUsers = (): Promise => fetch( 98 | '/api/users', 99 | { 100 | method: 'GET', 101 | headers: { 'Content-Type': 'application/json' } 102 | } 103 | ) 104 | .then(checkResponse) 105 | .then((res) => res.json()) 106 | 107 | export const addUsers = ( 108 | user: NewUser 109 | ): Promise => fetch( 110 | '/api/users', 111 | { 112 | method: 'POST', 113 | body: JSON.stringify(user), 114 | headers: { 'Content-Type': 'application/json' } 115 | } 116 | ) 117 | .then(checkResponse) 118 | .then((res) => res.json()) 119 | 120 | export const deleteUsers = (id: string): Promise => fetch( 121 | '/api/users/' + id, 122 | { 123 | method: 'DELETE', 124 | headers: { 'Content-Type': 'application/json' } 125 | } 126 | ) 127 | .then(checkResponse) 128 | .then((res) => res.json()) 129 | 130 | export const editUser = ( 131 | id: string, 132 | user: NewUser 133 | ): Promise => fetch( 134 | '/api/users/' + id, 135 | { 136 | method: 'PUT', 137 | body: JSON.stringify(user), 138 | headers: { 'Content-Type': 'application/json' } 139 | } 140 | ) 141 | .then(checkResponse) 142 | .then((res) => res.json()) 143 | 144 | export const addUser = ( 145 | user: TwitchUser 146 | ): Promise => fetch( 147 | '/api/twitch', 148 | { 149 | method: 'POST', 150 | body: JSON.stringify(user), 151 | headers: { 'Content-Type': 'application/json' } 152 | } 153 | ) 154 | .then(checkResponse) 155 | 156 | export const logOut = (): Promise => fetch( 157 | '/api/twitch/logout', 158 | { 159 | method: 'POST', 160 | headers: { 'Content-Type': 'application/json' } 161 | } 162 | ) 163 | .then(checkResponse) 164 | -------------------------------------------------------------------------------- /server/src/datastore.ts: -------------------------------------------------------------------------------- 1 | import * as jsonfile from 'jsonfile' 2 | import * as fs from 'fs' 3 | import * as uuid from 'uuid/v5' 4 | 5 | import config from './config' 6 | 7 | import { Sound, SoundRequest } from './types' 8 | import { deleteFile as deleteUtil } from './utils' 9 | 10 | const fileSongs = './db/db-sounds.json' 11 | 12 | const readSounds = (): Promise => new Promise((resolve, reject) => { 13 | jsonfile.readFile(fileSongs, (err, mainObj: Sound[]) => { 14 | if (err) reject(err) 15 | resolve(mainObj) 16 | }) 17 | }) 18 | 19 | const setSounds = ( 20 | data: Sound[] 21 | ) => new Promise((resolve, reject) => { 22 | jsonfile.writeFile(fileSongs, data, (err) => { 23 | if (err) reject(err) 24 | resolve() 25 | }) 26 | }) 27 | 28 | export const deleteFile = ( 29 | path: string 30 | ): Promise => new Promise((resolve, reject) => { 31 | try { 32 | resolve(fs.unlinkSync(path)) 33 | } 34 | catch (err) { 35 | console.log(err) 36 | reject(err) 37 | } 38 | }) 39 | 40 | export const isCommandFree = async ( 41 | command: string 42 | ): Promise => { 43 | if (command === '' || command === '!') { 44 | throw new Error('Given command is empty') 45 | } 46 | const songs = await readSounds() 47 | const found = songs.find(s => s.command === command) 48 | return found === undefined 49 | } 50 | 51 | export const findSoundById = async ( 52 | id: string 53 | ) => { 54 | if (id === '') { 55 | throw new Error('Given ID is empty') 56 | } 57 | const songs = await readSounds() 58 | const found = songs.find(s => s.id === id) 59 | if (!found) { 60 | throw new Error('Sound was not found') 61 | } 62 | return found 63 | } 64 | 65 | export const addSound = async ( 66 | sound: SoundRequest 67 | ): Promise => { 68 | try { 69 | const isFree = await isCommandFree(sound.command) 70 | if (isFree) { 71 | const sounds = await readSounds() 72 | const newSound: Sound = { 73 | ...sound, 74 | id: uuid(sound.command, config.uuidNameSpace) 75 | } 76 | await setSounds([ ...sounds, newSound ]) 77 | 78 | return newSound 79 | } 80 | else { 81 | throw new Error('Sound command is already taken!') 82 | } 83 | } 84 | catch (err) { 85 | console.log(err) 86 | throw new Error(err) 87 | } 88 | } 89 | 90 | export const updateSound = async ( 91 | id: string, 92 | sound: SoundRequest, 93 | hasNewSound: boolean 94 | ): Promise => { 95 | try { 96 | const oldSound = await findSoundById(id) 97 | const soundList = await readSounds() 98 | 99 | const isFree = await isCommandFree(sound.command) 100 | 101 | if (sound.command !== oldSound.command && !isFree) { 102 | if (hasNewSound) { 103 | deleteUtil(sound.path) 104 | } 105 | throw new Error('This command is already taken') 106 | } 107 | else { 108 | if (hasNewSound) { 109 | deleteUtil(oldSound.path) 110 | } 111 | } 112 | 113 | const newSound: Sound = { 114 | id: oldSound.id, 115 | ...sound 116 | } 117 | 118 | const updated = soundList.map(l => l.id === newSound.id ? newSound : l) 119 | await setSounds(updated) 120 | 121 | return newSound 122 | } 123 | catch (err) { 124 | console.log(err) 125 | throw new Error(err) 126 | } 127 | } 128 | 129 | export const deleteSound = async ( 130 | id: string 131 | ): Promise => { 132 | try { 133 | const oldSound = await findSoundById(id) 134 | const soundList = await readSounds() 135 | 136 | await deleteFile(oldSound.path) 137 | const updated = soundList.filter(l => l.id !== oldSound.id) 138 | await setSounds(updated) 139 | 140 | return updated 141 | } 142 | catch (err) { 143 | console.log(err) 144 | throw new Error(err) 145 | } 146 | } 147 | 148 | export const fetchSounds = async (): Promise => readSounds() 149 | export const migrateSoundDB = async () => setSounds([]) 150 | 151 | export const migrateSoundDatabase = async () => { 152 | try { 153 | await fetchSounds() 154 | } 155 | catch (e) { 156 | console.log('Migrating sound DB') 157 | await migrateSoundDB() 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /client/src/components/dashboard/dialogs/EditUserDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction, Fragment } from 'react' 2 | 3 | import { UserFlags, User } from '../../../types' 4 | import userFlags from '../../../enums/userFlags' 5 | import { customColors } from '../../../theme' 6 | 7 | import { 8 | Dialog, 9 | DialogActions, 10 | DialogTitle, 11 | DialogContent, 12 | Button, 13 | TextField, 14 | createStyles, 15 | makeStyles, 16 | Theme, 17 | FormControl, 18 | InputLabel, 19 | Select, 20 | Input, 21 | MenuItem, 22 | Chip 23 | } from '@material-ui/core' 24 | 25 | const useStyles = makeStyles((theme: Theme) => 26 | createStyles({ 27 | text: { 28 | margin: theme.spacing(2, 0), 29 | width: '100%' 30 | }, 31 | error: { 32 | color: customColors.danger.main 33 | }, 34 | input: { 35 | display: 'none' 36 | }, 37 | icon: { 38 | marginLeft: theme.spacing(1) 39 | }, 40 | replay: { 41 | marginRight: theme.spacing(2) 42 | }, 43 | menuitem: { 44 | verticalAlign: 'middle' 45 | }, 46 | menuItemText: { 47 | display: 'inline-flex', 48 | verticalAlign: 'super', 49 | fontWeight: 400, 50 | marginLeft: theme.spacing(1) 51 | }, 52 | chip: { 53 | margin: theme.spacing(0, 0.5), 54 | cursor: 'pointer' 55 | }, 56 | chips: { 57 | display: 'flex', 58 | flexWrap: 'wrap', 59 | cursor: 'pointer' 60 | } 61 | }) 62 | ) 63 | 64 | const ITEM_HEIGHT = 48 65 | const ITEM_PADDING_TOP = 8 66 | const MenuProps = { 67 | PaperProps: { 68 | style: { 69 | maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, 70 | width: 250 71 | } 72 | } 73 | } 74 | 75 | interface Props { 76 | isOpen: boolean, 77 | onClose: () => void, 78 | onEdit: (sound: User) => Promise, 79 | user: User | null, 80 | setUser: Dispatch> 81 | } 82 | 83 | export default ({ 84 | isOpen, 85 | onClose, 86 | onEdit, 87 | user, 88 | setUser 89 | }: Props) => { 90 | const classes = useStyles() 91 | 92 | const _handleClose = () => { 93 | onClose() 94 | } 95 | 96 | const handleChange = (e: React.ChangeEvent<{ value: unknown }>) => { 97 | if (user) { 98 | setUser({ 99 | ...user, 100 | flags: e.target.value as UserFlags[] 101 | }) 102 | } 103 | } 104 | 105 | const _handleEditUser = () => { 106 | if (user && user.username !== '') { 107 | onEdit(user) 108 | .then(() => onClose()) 109 | .catch(() => onClose()) 110 | } 111 | } 112 | 113 | if (user === null) { 114 | return 115 | } 116 | 117 | return ( 118 | 125 | Add new user 126 | 127 | setUser({ 131 | ...user, 132 | username: e.currentTarget.value 133 | })} 134 | fullWidth 135 | variant='outlined' 136 | className={classes.text} 137 | /> 138 | 139 | Chip 140 | 161 | 162 | 163 | 164 | 167 | {user.username !== '' && ( 168 | 171 | )} 172 | 173 | 174 | ) 175 | } 176 | -------------------------------------------------------------------------------- /client/src/components/dashboard/dialogs/NewUserDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import { UserFlags, NewUser } from '../../../types' 4 | import userFlags from '../../../enums/userFlags' 5 | import { customColors } from '../../../theme' 6 | 7 | import { 8 | Dialog, 9 | DialogActions, 10 | DialogTitle, 11 | DialogContent, 12 | Button, 13 | TextField, 14 | createStyles, 15 | makeStyles, 16 | Theme, 17 | FormControl, 18 | InputLabel, 19 | Select, 20 | Input, 21 | MenuItem, 22 | Chip 23 | } from '@material-ui/core' 24 | 25 | const useStyles = makeStyles((theme: Theme) => 26 | createStyles({ 27 | text: { 28 | margin: theme.spacing(2, 0), 29 | width: '100%' 30 | }, 31 | error: { 32 | color: customColors.danger.main 33 | }, 34 | input: { 35 | display: 'none' 36 | }, 37 | icon: { 38 | marginLeft: theme.spacing(1) 39 | }, 40 | replay: { 41 | marginRight: theme.spacing(2) 42 | }, 43 | menuitem: { 44 | verticalAlign: 'middle' 45 | }, 46 | menuItemText: { 47 | display: 'inline-flex', 48 | verticalAlign: 'super', 49 | fontWeight: 400, 50 | marginLeft: theme.spacing(1) 51 | }, 52 | chip: { 53 | margin: theme.spacing(0, 0.5), 54 | cursor: 'pointer' 55 | }, 56 | chips: { 57 | display: 'flex', 58 | flexWrap: 'wrap', 59 | cursor: 'pointer' 60 | } 61 | }) 62 | ) 63 | 64 | const ITEM_HEIGHT = 48 65 | const ITEM_PADDING_TOP = 8 66 | const MenuProps = { 67 | PaperProps: { 68 | style: { 69 | maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, 70 | width: 250 71 | } 72 | } 73 | } 74 | 75 | interface Props { 76 | isOpen: boolean, 77 | onClose: () => void, 78 | onAdd: (sound: NewUser) => Promise 79 | } 80 | 81 | interface State { 82 | flags: UserFlags[], 83 | username: string, 84 | error: boolean 85 | } 86 | 87 | const getInitialState = (): State => ({ 88 | username: '', 89 | flags: [], 90 | error: false 91 | }) 92 | 93 | export default ({ 94 | isOpen, 95 | onClose, 96 | onAdd 97 | }: Props) => { 98 | const classes = useStyles() 99 | const [state, setState] = useState(getInitialState) 100 | 101 | const _handleClose = () => { 102 | onClose() 103 | setState(getInitialState) 104 | } 105 | 106 | const handleChange = (e: React.ChangeEvent<{ value: unknown }>) => { 107 | setState({ 108 | ...state, 109 | flags: e.target.value as UserFlags[] 110 | }) 111 | } 112 | 113 | const _handleNewUser = () => { 114 | if (state.username !== '') { 115 | onAdd({ 116 | username: state.username, 117 | flags: state.flags 118 | }) 119 | .then(() => { 120 | onClose() 121 | setState(getInitialState) 122 | }) 123 | .catch(() => { 124 | setState({ 125 | ...state, 126 | error: true 127 | }) 128 | }) 129 | } 130 | } 131 | 132 | return ( 133 | 140 | Add new user 141 | 142 | setState({ 146 | ...state, 147 | username: e.currentTarget.value 148 | })} 149 | fullWidth 150 | error={state.error} 151 | variant='outlined' 152 | className={classes.text} 153 | /> 154 | 155 | Chip 156 | 177 | 178 | 179 | 180 | 183 | {state.username !== '' && ( 184 | 187 | )} 188 | 189 | 190 | ) 191 | } 192 | -------------------------------------------------------------------------------- /server/src/endpoints.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as bodyParser from 'body-parser' 3 | 4 | import { Request, Response, Router } from 'express' 5 | import { fetchSounds, addSound, deleteSound, updateSound, findSoundById } from './datastore' 6 | import { fetchUsers, addUser, deleteUser, updateUser } from './users' 7 | import { SoundRequest } from './types' 8 | 9 | import soundUpload from './soundupload' 10 | import twitchConnection from './twitch' 11 | 12 | const router = Router() 13 | 14 | router.use(bodyParser.urlencoded({ extended: false })) 15 | router.use(bodyParser.json()) 16 | 17 | router.post('/sounds', soundUpload.single('sound'), async (req: Request, res: Response) => { 18 | try { 19 | const { access, command, level } = req.body 20 | const newSong = await addSound({ 21 | access, 22 | command, 23 | path: req.file.path, 24 | level: Number(level) 25 | }) 26 | 27 | return res.status(200).send(newSong) 28 | } 29 | catch (e) { 30 | return res 31 | .status(500) 32 | .send(e) 33 | } 34 | }) 35 | 36 | router.get('/sounds', async (req: Request, res: Response) => { 37 | try { 38 | const sounds = await fetchSounds() 39 | return res.status(200).send(sounds) 40 | } 41 | catch (e) { 42 | return res 43 | .status(500) 44 | .send(e) 45 | } 46 | }) 47 | 48 | router.delete('/sounds/:id', async (req: Request, res: Response) => { 49 | try { 50 | const sounds = await deleteSound(req.params.id) 51 | return res.status(200).send(sounds) 52 | } 53 | catch (e) { 54 | return res 55 | .status(500) 56 | .send(e) 57 | } 58 | }) 59 | 60 | router.put('/sounds/:id/upload', soundUpload.single('sound'), async (req: Request, res: Response) => { 61 | try { 62 | const { access, command, level } = req.body 63 | const updated: SoundRequest = { 64 | access, 65 | command, 66 | path: req.file.path, 67 | level: Number(level) 68 | } 69 | 70 | const sound = await updateSound(req.params.id, updated, true) 71 | return res.status(200).send(sound) 72 | } 73 | catch (e) { 74 | return res 75 | .status(500) 76 | .send(e) 77 | } 78 | }) 79 | 80 | router.put('/sounds/:id/standard', async (req: Request, res: Response) => { 81 | try { 82 | const { access, command, level } = req.body 83 | const oldSound = await findSoundById(req.params.id) 84 | 85 | const updated: SoundRequest = { 86 | access, 87 | command, 88 | path: oldSound.path, 89 | level: Number(level) 90 | } 91 | 92 | const sound = await updateSound(req.params.id, updated, false) 93 | return res.status(200).send(sound) 94 | } 95 | catch (e) { 96 | return res 97 | .status(500) 98 | .send(e) 99 | } 100 | }) 101 | 102 | router.get('/twitch/auth', async (req: Request, res: Response) => { 103 | const auth = await twitchConnection.isAuth() 104 | return res.status(200).send(auth) 105 | }) 106 | 107 | router.post('/twitch/logout', async (req: Request, res: Response) => { 108 | const auth = await twitchConnection.disconnect() 109 | return res.status(200).send(auth) 110 | }) 111 | 112 | router.get('/twitch', (req: Request, res: Response) => { 113 | try { 114 | const config = twitchConnection.getConfig() 115 | return res.status(200).send(config) 116 | } 117 | catch (e) { 118 | return res 119 | .status(500) 120 | .send(e) 121 | } 122 | }) 123 | 124 | router.post('/twitch', async (req: Request, res: Response) => { 125 | try { 126 | const config = await twitchConnection.updateConfig({ 127 | username: req.body.username, 128 | oauth: req.body.oauth, 129 | channels: req.body.channels 130 | }) 131 | return res.status(200).send(config) 132 | } 133 | catch (e) { 134 | return res 135 | .status(500) 136 | .send(e) 137 | } 138 | }) 139 | 140 | router.get('/users', async (req: Request, res: Response) => { 141 | try { 142 | const users = await fetchUsers() 143 | return res.status(200).send(users) 144 | } 145 | catch (e) { 146 | return res 147 | .status(500) 148 | .send(e) 149 | } 150 | }) 151 | 152 | router.post('/users', async (req: Request, res: Response) => { 153 | try { 154 | const { username, flags } = req.body 155 | const user = await addUser({ username, flags }) 156 | return res.status(200).send(user) 157 | } 158 | catch (e) { 159 | return res 160 | .status(500) 161 | .send(e) 162 | } 163 | }) 164 | 165 | router.put('/users/:id', async (req: Request, res: Response) => { 166 | try { 167 | const { username, flags, id } = req.body 168 | const user = await updateUser(req.params.id, { id, username, flags }) 169 | return res.status(200).send(user) 170 | } 171 | catch (e) { 172 | return res 173 | .status(500) 174 | .send(e) 175 | } 176 | }) 177 | 178 | router.delete('/users/:id', async (req: Request, res: Response) => { 179 | try { 180 | const users = await deleteUser(req.params.id) 181 | return res.status(200).send(users) 182 | } 183 | catch (e) { 184 | return res 185 | .status(500) 186 | .send(e) 187 | } 188 | }) 189 | 190 | export default router 191 | -------------------------------------------------------------------------------- /client/src/components/dashboard/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Home, Menu, MusicNote, Fire, Account } from 'mdi-material-ui' 3 | import { Switch, Route, Link } from 'react-router-dom' 4 | import { useRouter } from '../../hooks' 5 | import clsx from 'clsx' 6 | 7 | import HomePage from './HomePage' 8 | import SoundPage from './SoundPage' 9 | import ConnectionPage from './ConnectionPage' 10 | import UserPage from './UserPage' 11 | 12 | import { 13 | AppBar, 14 | Toolbar, 15 | IconButton, 16 | Typography as T, 17 | Drawer, 18 | List, 19 | ListItem, 20 | ListItemText, 21 | ListItemIcon, 22 | makeStyles, 23 | createStyles, 24 | Theme 25 | } from '@material-ui/core' 26 | 27 | const routes = [ 28 | { 29 | name: 'Home', 30 | path: '/dashboard', 31 | icon: Home, 32 | component: HomePage 33 | }, 34 | { 35 | name: 'Sound Manager', 36 | path: '/dashboard/sounds', 37 | icon: MusicNote, 38 | component: SoundPage 39 | }, 40 | { 41 | name: 'Users', 42 | path: '/dashboard/users', 43 | icon: Account, 44 | component: UserPage 45 | }, 46 | { 47 | name: 'Connections', 48 | path: '/dashboard/connection', 49 | icon: Fire, 50 | component: ConnectionPage 51 | } 52 | ] 53 | 54 | const drawerWidth = 240 55 | 56 | const useStyles = makeStyles((theme: Theme) => 57 | createStyles({ 58 | root: { 59 | display: 'flex' 60 | }, 61 | appBar: { 62 | backgroundColor: theme.palette.primary.main 63 | }, 64 | menuButton: { 65 | marginLeft: theme.spacing(-0.75), 66 | marginRight: theme.spacing(1) 67 | }, 68 | title: { 69 | flexGrow: 1 70 | }, 71 | iconButton: { 72 | marginRight: theme.spacing(-0.75), 73 | marginLeft: theme.spacing(1) 74 | }, 75 | drawer: { 76 | width: drawerWidth, 77 | flexShrink: 0, 78 | whiteSpace: 'nowrap' 79 | }, 80 | drawerOpen: { 81 | width: drawerWidth, 82 | transition: theme.transitions.create('width', { 83 | easing: theme.transitions.easing.sharp, 84 | duration: theme.transitions.duration.enteringScreen 85 | }) 86 | }, 87 | drawerClose: { 88 | transition: theme.transitions.create('width', { 89 | easing: theme.transitions.easing.sharp, 90 | duration: theme.transitions.duration.leavingScreen 91 | }), 92 | overflowX: 'hidden', 93 | width: theme.spacing(7), 94 | [theme.breakpoints.up('sm')]: { 95 | width: theme.spacing(8) 96 | } 97 | }, 98 | toolbar: { 99 | display: 'flex', 100 | alignItems: 'center', 101 | justifyContent: 'flex-end', 102 | padding: '0 8px', 103 | ...theme.mixins.toolbar 104 | }, 105 | drawerSelect: { 106 | borderLeft: `4px solid ${theme.palette.primary.dark}` 107 | }, 108 | content: { 109 | flexGrow: 1, 110 | padding: theme.spacing(3) 111 | } 112 | }) 113 | ) 114 | 115 | export default () => { 116 | const [ navActive, setNavActive ] = useState(true) 117 | 118 | const classes = useStyles() 119 | const { location } = useRouter() 120 | 121 | return ( 122 |
123 | 124 | 125 | setNavActive(!navActive)} 131 | > 132 | 133 | 134 | 135 | Twitch Play Sounds 136 | 137 | 138 | 139 | 153 |
154 | 155 | {routes.map(route => ( 156 | 163 | 164 | 165 | 166 | {route.name} 167 | 168 | ))} 169 | 170 | 171 |
172 |
173 | 174 | {routes.map(route => ( 175 | } 180 | /> 181 | ))} 182 | 183 |
184 |
185 | ) 186 | } 187 | -------------------------------------------------------------------------------- /server/src/twitch.ts: -------------------------------------------------------------------------------- 1 | import * as jsonfile from 'jsonfile' 2 | import { Client, ChatUserstate } from 'tmi.js' 3 | import { TwitchConfig } from './types' 4 | import config from './config' 5 | import { dispatchSocket } from './socket' 6 | import { SOCKET, PERMISSIONS } from './enum' 7 | import { fetchSounds } from './datastore' 8 | import { findUser } from './users' 9 | 10 | const fileConfig = './db/db-twitch.json' 11 | 12 | class TwitchConnection { 13 | connected: boolean 14 | username: string | null 15 | oauth: string | null 16 | channels: string[] | null 17 | 18 | client: null | Client 19 | 20 | constructor () { 21 | this.connected = false 22 | this.username = null 23 | this.oauth = null 24 | this.channels = null 25 | this.client = null 26 | } 27 | 28 | readConfig = (): Promise => new Promise((resolve, reject) => { 29 | jsonfile.readFile(fileConfig, (err, cfg: TwitchConfig) => { 30 | if (err) reject(err) 31 | resolve(cfg) 32 | }) 33 | }) 34 | 35 | setConfig = ( 36 | data: TwitchConfig 37 | ) => new Promise((resolve, reject) => { 38 | jsonfile.writeFile(fileConfig, data, (err) => { 39 | if (err) reject(err) 40 | resolve() 41 | }) 42 | }) 43 | 44 | onStartConnect = async () => { 45 | try { 46 | await this.readConfig() 47 | } 48 | catch (e) { 49 | console.log('Migrating twitch config DB') 50 | await this.setConfig({ 51 | username: null, 52 | oauth: null, 53 | channels: null 54 | }) 55 | } 56 | await this.loadConfig() 57 | 58 | if (this.oauth !== null) { 59 | try { 60 | await this.auth() 61 | } 62 | catch (e) { 63 | await this.resetConfig() 64 | } 65 | } 66 | } 67 | 68 | loadConfig = async () => { 69 | const config = await this.readConfig() 70 | this.username = config.username 71 | this.oauth = config.oauth 72 | this.channels = config.channels 73 | } 74 | 75 | updateConfig = async (config: TwitchConfig) => { 76 | await this.setConfig(config) 77 | this.username = config.username 78 | this.oauth = config.oauth 79 | this.channels = config.channels 80 | if (this.client && this.client.readyState() === 'OPEN') { 81 | await this.client.disconnect() 82 | await this.auth() 83 | } 84 | else { 85 | await this.auth() 86 | } 87 | } 88 | 89 | resetConfig = async () => { 90 | this.username = null 91 | this.oauth = null 92 | this.channels = null 93 | await this.setConfig({ 94 | username: null, 95 | oauth: null, 96 | channels: null 97 | }) 98 | } 99 | 100 | handleMessage = async ( 101 | channel: string, 102 | user: ChatUserstate, 103 | message: string 104 | ) => { 105 | if (message.charAt(0) === '!') { 106 | const sounds = await fetchSounds() 107 | for (const sound of sounds) { 108 | if (message === sound.command) { 109 | const raw = user['badges-raw'] 110 | const isUser = await findUser(user.username) 111 | 112 | if (isUser && isUser.flags.includes(PERMISSIONS.BANNED)) { 113 | return 114 | } 115 | 116 | switch (sound.access) { 117 | case 'ALL': 118 | dispatchSocket(SOCKET.PLAYER, sound) 119 | break 120 | case 'MOD': 121 | if (user.mod || (isUser && isUser.flags.includes(PERMISSIONS.ALL_ACCESS))) { 122 | dispatchSocket(SOCKET.PLAYER, sound) 123 | } 124 | break 125 | case 'SUB': 126 | if (user.subscriber 127 | || user.mod 128 | || (raw && raw.toLowerCase().includes('vip')) 129 | || (isUser && isUser.flags.includes(PERMISSIONS.ALL_ACCESS)) 130 | ) { 131 | dispatchSocket(SOCKET.PLAYER, sound) 132 | } 133 | break 134 | case 'VIP': 135 | if (user.mod 136 | || (raw && raw.toLowerCase().includes('vip')) 137 | || (isUser && isUser.flags.includes(PERMISSIONS.ALL_ACCESS)) 138 | ) { 139 | dispatchSocket(SOCKET.PLAYER, sound) 140 | } 141 | break 142 | } 143 | } 144 | } 145 | } 146 | } 147 | 148 | auth = async () => { 149 | try { 150 | if (this.username === null || this.oauth === null || this.channels === null) { 151 | throw new Error('No connection set!') 152 | } 153 | 154 | const options = { 155 | options: { 156 | debug: config.environment === 'DEVELOPMENT' 157 | }, 158 | connection: { 159 | cluster: 'aws', 160 | reconnect: true 161 | }, 162 | identity: { 163 | username: this.username, 164 | password: this.oauth 165 | }, 166 | channels: this.channels 167 | } 168 | 169 | this.client = Client(options) 170 | this.client.connect() 171 | 172 | this.client.on('connected', () => { 173 | this.connected = true 174 | dispatchSocket(SOCKET.TWITCH, true) 175 | }) 176 | 177 | this.client.on('disconnected', () => { 178 | this.connected = false 179 | dispatchSocket(SOCKET.TWITCH, false) 180 | }) 181 | 182 | this.client.on('chat', (channel, user, message) => { 183 | this.handleMessage(channel, user, message) 184 | }) 185 | } 186 | catch (e) { 187 | await this.resetConfig() 188 | return e 189 | } 190 | } 191 | 192 | getConfig = (): TwitchConfig => ({ 193 | username: this.username, 194 | oauth: this.oauth, 195 | channels: this.channels 196 | }) 197 | 198 | isAuth = (): boolean => this.connected 199 | 200 | disconnect = async () => { 201 | if (this.client) { 202 | await this.client.disconnect() 203 | this.connected = false 204 | dispatchSocket(SOCKET.TWITCH, false) 205 | await this.resetConfig() 206 | } 207 | } 208 | } 209 | 210 | const twitchConnection = new TwitchConnection() 211 | 212 | export default twitchConnection 213 | -------------------------------------------------------------------------------- /client/src/components/dashboard/dialogs/EditSoundDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction, Fragment } from 'react' 2 | 3 | import { EditSound } from '../../../types' 4 | import accessLevel from '../../../enums/accessLevels' 5 | import { customColors } from '../../../theme' 6 | import { IconHelper } from '../' 7 | 8 | import { 9 | Dialog, 10 | DialogActions, 11 | DialogTitle, 12 | DialogContent, 13 | Button, 14 | TextField, 15 | FormControl, 16 | InputLabel, 17 | OutlinedInput, 18 | Select, 19 | MenuItem, 20 | createStyles, 21 | makeStyles, 22 | Theme, 23 | Typography as T, 24 | Grid, 25 | Input, 26 | Slider, 27 | IconButton, 28 | Tooltip 29 | } from '@material-ui/core' 30 | 31 | import { 32 | FileUploadOutline, 33 | VolumeHigh, 34 | Play 35 | } from 'mdi-material-ui' 36 | 37 | const useStyles = makeStyles((theme: Theme) => 38 | createStyles({ 39 | text: { 40 | margin: theme.spacing(2, 0) 41 | }, 42 | error: { 43 | color: customColors.danger.main 44 | }, 45 | input: { 46 | display: 'none' 47 | }, 48 | icon: { 49 | marginLeft: theme.spacing(1) 50 | }, 51 | replay: { 52 | marginRight: theme.spacing(2) 53 | }, 54 | menuitem: { 55 | verticalAlign: 'middle' 56 | }, 57 | menuItemText: { 58 | display: 'inline-flex', 59 | verticalAlign: 'super', 60 | fontWeight: 400, 61 | marginLeft: theme.spacing(1) 62 | } 63 | }) 64 | ) 65 | 66 | interface Props { 67 | isOpen: boolean, 68 | onClose: () => void, 69 | onEdit: (sound: EditSound) => Promise, 70 | sound: EditSound | null, 71 | setSound: Dispatch> 72 | } 73 | 74 | export default ({ 75 | isOpen, 76 | onClose, 77 | onEdit, 78 | sound, 79 | setSound 80 | }: Props) => { 81 | const classes = useStyles() 82 | 83 | const _handleClose = () => { 84 | onClose() 85 | } 86 | 87 | const _handleUpload = () => { 88 | if (sound !== null && sound.command !== '') { 89 | onEdit({ 90 | id: sound.id, 91 | access: sound.access, 92 | command: sound.command, 93 | file: sound.file, 94 | level: Number(sound.level), 95 | path: sound.path 96 | }) 97 | .then(() => { 98 | onClose() 99 | }) 100 | .catch(() => { 101 | setSound(null) 102 | }) 103 | } 104 | } 105 | 106 | const handleBlur = () => { 107 | if (sound && sound.level < 0) { 108 | setSound({ ...sound, level: 0 }) 109 | } 110 | else if (sound && sound.level > 100) { 111 | setSound({ ...sound, level: 100 }) 112 | } 113 | } 114 | 115 | const _playSound = () => { 116 | if ((sound && sound.file) || (sound && sound.path)) { 117 | const audio = new Audio(sound.file !== null 118 | ? window.URL.createObjectURL(sound.file) 119 | : '/' + sound.path) 120 | 121 | const volumeLevel = (Number(sound.level) / 100) 122 | audio.volume = 0.75 * volumeLevel 123 | audio.play() 124 | } 125 | } 126 | 127 | if (sound === null) { 128 | return 129 | } 130 | 131 | return ( 132 | 139 | Add new sound 140 | 141 | setSound({ 145 | ...sound, 146 | command: e.currentTarget.value 147 | })} 148 | fullWidth 149 | variant='outlined' 150 | className={classes.text} 151 | /> 152 | 153 | 154 | Select Access Level 155 | 156 | 179 | 180 | setSound({ 185 | ...sound, 186 | file: e.target.files && e.target.files[0] 187 | ? e.target.files[0] 188 | : null 189 | })} 190 | /> 191 | 206 |
207 | 208 | Volume 209 | 210 | 211 | 212 | 213 | 214 | 215 | { 218 | if (sound && sound.level !== v) { 219 | setSound({ ...sound, level: Number(v) }) 220 | } 221 | }} 222 | step={5} 223 | aria-labelledby='input-slider' 224 | /> 225 | 226 | 227 | setSound({ ...sound, level: Number(e.target.value) })} 232 | onBlur={handleBlur} 233 | inputProps={{ 234 | step: 10, 235 | min: 0, 236 | max: 100, 237 | type: 'number', 238 | 'aria-labelledby': 'input-slider' 239 | }} 240 | /> 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 |
251 |
252 | 253 | 256 | {sound.command !== '' && ( 257 | 260 | )} 261 | 262 |
263 | ) 264 | } 265 | -------------------------------------------------------------------------------- /client/src/components/dashboard/dialogs/NewSoundDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import { AccessLevel, NewSound } from '../../../types' 4 | import accessLevel from '../../../enums/accessLevels' 5 | import { customColors } from '../../../theme' 6 | import { IconHelper } from '../' 7 | 8 | import { 9 | Dialog, 10 | DialogActions, 11 | DialogTitle, 12 | DialogContent, 13 | Button, 14 | TextField, 15 | FormControl, 16 | InputLabel, 17 | OutlinedInput, 18 | Select, 19 | MenuItem, 20 | createStyles, 21 | makeStyles, 22 | Theme, 23 | Typography as T, 24 | Grid, 25 | Input, 26 | Slider, 27 | IconButton, 28 | Tooltip 29 | } from '@material-ui/core' 30 | 31 | import { 32 | FileUploadOutline, 33 | VolumeHigh, 34 | Play 35 | } from 'mdi-material-ui' 36 | 37 | const useStyles = makeStyles((theme: Theme) => 38 | createStyles({ 39 | text: { 40 | margin: theme.spacing(2, 0) 41 | }, 42 | error: { 43 | color: customColors.danger.main 44 | }, 45 | input: { 46 | display: 'none' 47 | }, 48 | icon: { 49 | marginLeft: theme.spacing(1) 50 | }, 51 | replay: { 52 | marginRight: theme.spacing(2) 53 | }, 54 | menuitem: { 55 | verticalAlign: 'middle' 56 | }, 57 | menuItemText: { 58 | display: 'inline-flex', 59 | verticalAlign: 'super', 60 | fontWeight: 400, 61 | marginLeft: theme.spacing(1) 62 | } 63 | }) 64 | ) 65 | 66 | interface Props { 67 | isOpen: boolean, 68 | onClose: () => void, 69 | onAdd: (sound: NewSound) => Promise 70 | } 71 | 72 | interface State { 73 | access: AccessLevel, 74 | command: string, 75 | file: File | null, 76 | level: number | number[], 77 | error: boolean 78 | } 79 | 80 | const getInitialState = (): State => ({ 81 | access: 'ALL', 82 | command: '', 83 | file: null, 84 | level: 50, 85 | error: false 86 | }) 87 | 88 | export default ({ 89 | isOpen, 90 | onClose, 91 | onAdd 92 | }: Props) => { 93 | const classes = useStyles() 94 | const [state, setState] = useState(getInitialState) 95 | 96 | const _handleClose = () => { 97 | onClose() 98 | setState(getInitialState) 99 | } 100 | 101 | const _handleUpload = () => { 102 | if (state.file !== null && state.command !== '') { 103 | onAdd({ 104 | access: state.access, 105 | command: state.command, 106 | file: state.file, 107 | level: Number(state.level) 108 | }) 109 | .then(() => { 110 | onClose() 111 | setState(getInitialState) 112 | }) 113 | .catch(() => { 114 | setState({ 115 | ...state, 116 | error: true 117 | }) 118 | }) 119 | } 120 | } 121 | 122 | const handleBlur = () => { 123 | if (state.level < 0) { 124 | setState({ ...state, level: 0 }) 125 | } 126 | else if (state.level > 100) { 127 | setState({ ...state, level: 100 }) 128 | } 129 | } 130 | 131 | const _playSound = () => { 132 | if (state.file) { 133 | const audio = new Audio(window.URL.createObjectURL(state.file)) 134 | const volumeLevel = (Number(state.level) / 100) 135 | audio.volume = 0.75 * volumeLevel 136 | audio.play() 137 | } 138 | } 139 | 140 | return ( 141 | 148 | Add new sound 149 | 150 | setState({ 154 | ...state, 155 | command: e.currentTarget.value 156 | })} 157 | fullWidth 158 | error={state.error} 159 | variant='outlined' 160 | className={classes.text} 161 | /> 162 | 163 | 164 | Select Access Level 165 | 166 | 191 | 192 | setState({ 197 | ...state, 198 | file: e.target.files && e.target.files[0] 199 | ? e.target.files[0] 200 | : null 201 | })} 202 | /> 203 | 218 | {state.file !== null && ( 219 |
220 | 221 | Volume 222 | 223 | 224 | 225 | 226 | 227 | 228 | { 231 | if (state.level !== v) { 232 | setState({ ...state, level: v }) 233 | } 234 | }} 235 | step={5} 236 | aria-labelledby='input-slider' 237 | /> 238 | 239 | 240 | setState({ ...state, level: Number(e.target.value) })} 245 | onBlur={handleBlur} 246 | inputProps={{ 247 | step: 10, 248 | min: 0, 249 | max: 100, 250 | type: 'number', 251 | 'aria-labelledby': 'input-slider' 252 | }} 253 | /> 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 |
264 | )} 265 |
266 | 267 | 270 | {state.file !== null && state.command !== '' && ( 271 | 274 | )} 275 | 276 |
277 | ) 278 | } 279 | --------------------------------------------------------------------------------