frontend: initial commit
This commit is contained in:
parent
8d70acc5d2
commit
1ad243f043
21 changed files with 30437 additions and 0 deletions
2
frontend/.gitignore
vendored
Normal file
2
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
yarn*
|
||||
76
frontend/bin.js
Executable file
76
frontend/bin.js
Executable file
|
|
@ -0,0 +1,76 @@
|
|||
#!/usr/bin/env node
|
||||
const p = require('path')
|
||||
const { build, cliopts } = require('estrella')
|
||||
const fs = require('fs/promises')
|
||||
const copy = require('recursive-copy')
|
||||
|
||||
const [opts, args] = cliopts.parse(
|
||||
['host', 'Development server: Hostname', '<host>'],
|
||||
['port', 'Development server: Port']
|
||||
)
|
||||
|
||||
const base = process.cwd()
|
||||
const outdir = p.join(base, 'build')
|
||||
const staticdir = p.join(base, 'static')
|
||||
const entry = p.join(base, 'src/index.js')
|
||||
const outfile = p.join(outdir, 'bundle.js')
|
||||
let devServerMessage
|
||||
let firstRun = true
|
||||
|
||||
build({
|
||||
entry,
|
||||
outfile,
|
||||
// external: ['http', 'https'],
|
||||
bundle: true,
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
loader: {
|
||||
'.js': 'jsx',
|
||||
'.woff': 'file',
|
||||
'.woff2': 'file'
|
||||
},
|
||||
// This banner fixes some modules that were designed for Node.js
|
||||
// to run in the browser by providing minimal shims.
|
||||
banner: `
|
||||
var global = window;
|
||||
window.process = {
|
||||
title: "browser",
|
||||
env: {},
|
||||
nextTick: function (cb, ...args) {
|
||||
Promise.resolve().then(() => cb(...args))
|
||||
}
|
||||
};
|
||||
`,
|
||||
define: {
|
||||
'process.title': 'browser',
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development')
|
||||
},
|
||||
// dgram external needed for osc-js to compile
|
||||
external: ['dgram'],
|
||||
onEnd
|
||||
})
|
||||
|
||||
// Run a local web server with livereload when -watch is set
|
||||
if (cliopts.watch) {
|
||||
const instant = require('instant')
|
||||
const express = require('express')
|
||||
const port = cliopts.port || 3000
|
||||
const host = opts.host || 'localhost'
|
||||
const app = express()
|
||||
app.use(instant({ root: outdir }))
|
||||
app.listen(port, host, () => {
|
||||
devServerMessage = `Listening on http://${host}:${port} and watching for changes ...`
|
||||
console.log(devServerMessage)
|
||||
})
|
||||
}
|
||||
|
||||
async function onEnd () {
|
||||
if (!firstRun) return
|
||||
firstRun = false
|
||||
if (devServerMessage) console.log(devServerMessage)
|
||||
try {
|
||||
const stat = await fs.stat(staticdir)
|
||||
if (!stat.isDirectory()) return
|
||||
await copy(staticdir, outdir)
|
||||
} catch (err) {}
|
||||
}
|
||||
29224
frontend/build/bundle.js
Normal file
29224
frontend/build/bundle.js
Normal file
File diff suppressed because it is too large
Load diff
1
frontend/build/bundle.js.map
Normal file
1
frontend/build/bundle.js.map
Normal file
File diff suppressed because one or more lines are too long
11
frontend/build/index.html
Normal file
11
frontend/build/index.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>arsonar</title>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="bundle.css">
|
||||
</head>
|
||||
<body>
|
||||
<script src="bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "@studiox/frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"build": "node bin.js",
|
||||
"dev": "node bin.js -w"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@chakra-ui/core": "^0.6.1",
|
||||
"@emotion/core": "^10.0.28",
|
||||
"@emotion/styled": "^10.0.27",
|
||||
"debug": "^4.1.1",
|
||||
"emotion-theming": "^10.0.27",
|
||||
"osc-js": "^2.1.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-hook-form": "^5.1.1",
|
||||
"react-router": "^5.1.2",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"reconnecting-websocket": "^4.4.0",
|
||||
"split2": "^3.1.1",
|
||||
"thunky": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"estrella": "^1.3.0",
|
||||
"express": "^4.17.1",
|
||||
"instant": "^1.11.0",
|
||||
"recursive-copy": "^2.0.11"
|
||||
}
|
||||
}
|
||||
45
frontend/src/App.js
Normal file
45
frontend/src/App.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React from 'react'
|
||||
// import { hot } from 'react-hot-loader/root'
|
||||
import { Flex, ThemeProvider, CSSReset, Box } from '@chakra-ui/core'
|
||||
|
||||
import createTheme from './theme'
|
||||
import Player from './comp/Player'
|
||||
import Mixer from './comp/Mixer'
|
||||
import Chat from './comp/Chat'
|
||||
import { Pane, Container } from './comp/Pane'
|
||||
|
||||
function App (props) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Container direction='row' minHeight='100vh' bg='#222' p={2}>
|
||||
<Pane flex={1}>
|
||||
<Mixer />
|
||||
</Pane>
|
||||
<Container direction='column'>
|
||||
<Pane>
|
||||
<Player label='DeckA' address='/player/decka' />
|
||||
</Pane>
|
||||
<Pane>
|
||||
<Player label='DeckB' address='/player/deckb' />
|
||||
</Pane>
|
||||
</Container>
|
||||
<Pane maxWidth='sm'>
|
||||
<Chat />
|
||||
</Pane>
|
||||
</Container>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
// export default hot(App)
|
||||
export default App
|
||||
|
||||
function Wrapper (props) {
|
||||
const { children } = props
|
||||
const theme = createTheme()
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CSSReset />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
26
frontend/src/comp/Bar.js
Normal file
26
frontend/src/comp/Bar.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react'
|
||||
import { Flex, Box } from '@chakra-ui/core'
|
||||
|
||||
export default function Bar (props) {
|
||||
return (
|
||||
<Flex
|
||||
borderBottom='barSeperator'
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Element (props) {
|
||||
return (
|
||||
<Box
|
||||
px={2}
|
||||
py={1}
|
||||
borderRight='barSeperator'
|
||||
fontFamily='mono'
|
||||
color='textBar'
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Bar.Element = Element
|
||||
61
frontend/src/comp/Button.js
Normal file
61
frontend/src/comp/Button.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import React, { useState } from 'react'
|
||||
import { PseudoBox } from '@chakra-ui/core'
|
||||
|
||||
export default function Button (props) {
|
||||
const {
|
||||
children,
|
||||
active,
|
||||
size = 12,
|
||||
square,
|
||||
variant = 'redgreen',
|
||||
...other
|
||||
} = props
|
||||
const bg = 'button.' + variant + (active ? '.on' : '.off')
|
||||
const width = square ? size : undefined
|
||||
const color = active ? 'black' : '#eee'
|
||||
// other._hover = {
|
||||
// boxShadow: '0 0 10px 2px rgba(255, 50, 200, 0.7)'
|
||||
// }
|
||||
return (
|
||||
<PseudoBox
|
||||
as='button'
|
||||
w={width}
|
||||
h={size}
|
||||
px={2}
|
||||
bg={bg}
|
||||
borderWidth='2px'
|
||||
borderStyle='solid'
|
||||
borderColor={bg}
|
||||
fontWeight='600'
|
||||
color={color}
|
||||
_focus={{
|
||||
border: 'inputFocus',
|
||||
boxShadow: 'inputFocus'
|
||||
}}
|
||||
_hover={{
|
||||
border: 'inputHover'
|
||||
}}
|
||||
{...other}
|
||||
>
|
||||
{children}
|
||||
</PseudoBox>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToggleButton ({
|
||||
defaultValue,
|
||||
onChange,
|
||||
...props
|
||||
}) {
|
||||
const [state, setState] = useState(defaultValue || false)
|
||||
return <Button active={state} onClick={onClick} {...props} />
|
||||
|
||||
function onClick (_e) {
|
||||
setState(state => {
|
||||
if (onChange) onChange(!state)
|
||||
return !state
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Button.Toggle = ToggleButton
|
||||
126
frontend/src/comp/Chat.js
Normal file
126
frontend/src/comp/Chat.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { Flex, Box, Input, Textarea } from '@chakra-ui/core'
|
||||
import Button from './Button'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
// import client from '@arso-project/sonar-ui/src/lib/client'
|
||||
// async function loadMessages () {
|
||||
// const records = await client.query('records', { schema: 'sonar.chat' })
|
||||
// console.log(records)
|
||||
// return records
|
||||
// }
|
||||
|
||||
function useMessages () {
|
||||
const [messages, setMessages] = useState([])
|
||||
return [messages, postMessage]
|
||||
|
||||
// useEffect(() => {
|
||||
// loadMessages()
|
||||
// .then(res => setMessages(res))
|
||||
// .catch(err => console.error(err))
|
||||
// }, [])
|
||||
|
||||
function postMessage (message) {
|
||||
console.log('post', message)
|
||||
message = { value: message }
|
||||
setMessages(messages => [...messages, message])
|
||||
// const { message } = values
|
||||
// try {
|
||||
// const record = {
|
||||
// schema: 'sonar.chat',
|
||||
// value: {
|
||||
// message
|
||||
// }
|
||||
// }
|
||||
// const id = await client.put(record)
|
||||
// console.log('done', id)
|
||||
// } catch (err) {
|
||||
// console.log('error', err)
|
||||
// setError('message', err.message)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
export default function Chat (props) {
|
||||
const [messages, postMessage] = useMessages()
|
||||
console.log('messages', messages)
|
||||
return (
|
||||
<Flex
|
||||
height='100%'
|
||||
direction='column'
|
||||
bg='black'
|
||||
>
|
||||
<Messages messages={messages} />
|
||||
<MessageBox onSubmit={postMessage} />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
function Messages (props) {
|
||||
const { messages } = props
|
||||
return (
|
||||
<Box
|
||||
flex={1}
|
||||
p={2}
|
||||
overflow='auto'
|
||||
>
|
||||
{messages.map((message, i) => (
|
||||
<Message key={i} message={message} />
|
||||
))}
|
||||
list
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function Message (props) {
|
||||
const { message } = props
|
||||
const text = message.value.message
|
||||
return (
|
||||
<Box p={2} fontSize='lg' fontFamily='mono' color='white'>
|
||||
{text}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageBox (props) {
|
||||
const { onSubmit } = props
|
||||
const { handleSubmit, register, setError } = useForm()
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Flex
|
||||
margin={2}
|
||||
>
|
||||
<Textarea
|
||||
w='full'
|
||||
h='12'
|
||||
resize='none'
|
||||
borderRadius={0}
|
||||
fontSize='lg'
|
||||
bg='black'
|
||||
color='white'
|
||||
fontFamily='mono'
|
||||
fontWeight='600'
|
||||
flex={1}
|
||||
p='2'
|
||||
m='0'
|
||||
// lineHeight='2em'
|
||||
border='4px solid black'
|
||||
borderColor='black'
|
||||
_focus={{
|
||||
border: '4px solid magenta'
|
||||
}}
|
||||
_hover={{
|
||||
border: '4px solid #440077'
|
||||
}}
|
||||
placeholder='type here to chat'
|
||||
ref={register()}
|
||||
name='message'
|
||||
/>
|
||||
<Button square onSubmit={handleSubmit(onSubmit)}>
|
||||
Send
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
)
|
||||
|
||||
}
|
||||
129
frontend/src/comp/Meter.js
Normal file
129
frontend/src/comp/Meter.js
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { Box } from '@chakra-ui/core'
|
||||
|
||||
// import client from './client'
|
||||
|
||||
const SCALE_MIN = -60
|
||||
const SCALE_MAX = 0
|
||||
|
||||
export default function Meter (props) {
|
||||
// style={{ position: 'absolute', left: 0, top: 0, bottom: 0, right: 0 }} {...props} />
|
||||
const style = {
|
||||
minWidth: 0,
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}
|
||||
return (
|
||||
<Box position='relative' width='100%' height='100%'>
|
||||
<SvgMeter style={style} {...props} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function SvgMeter (props) {
|
||||
const { min, max, value, style, redPeak = 80, width = 10, name } = props
|
||||
let peak = value2px(value, min, max, 0, 100)
|
||||
if (peak > 100) peak = 100
|
||||
const [peakDrop, updatePeakDrop] = usePeakDrop()
|
||||
useEffect(() => {
|
||||
updatePeakDrop(peak)
|
||||
}, [peak, min, max])
|
||||
const maskid = useMemo(() => 'svg-meter-mask-' + (name || Date.now()), [name])
|
||||
const peakRed = value2px(redPeak, min, max, 0, 100)
|
||||
const meter = useMemo(() => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox={`0 0 ${width} 100`}
|
||||
preserveAspectRatio='none'
|
||||
style={style}
|
||||
>
|
||||
<defs>
|
||||
<mask id={maskid}>
|
||||
<rect x='0' y={100 - peak} width={width} height={peak} fill='white' />
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
<g
|
||||
mask={`url(#${maskid})`}
|
||||
style={{ opacity: 0.9 }}
|
||||
>
|
||||
<rect
|
||||
y='0'
|
||||
x='0'
|
||||
height={100 - peakRed}
|
||||
width={width}
|
||||
style={{ fill: '#dd1100' }}
|
||||
/>
|
||||
<rect
|
||||
y={100 - peakRed}
|
||||
x='0'
|
||||
height={peakRed}
|
||||
width={width}
|
||||
style={{ fill: '#00dd00' }}
|
||||
/>
|
||||
/>
|
||||
</g>
|
||||
|
||||
<rect y={100 - peakDrop} x='0' width={width} height='0.5' fill='#00f' />
|
||||
<rect x='0' width={width} height='0.2' fill='#ff0' y={100 - peakRed} />
|
||||
</svg>
|
||||
), [peak, peakDrop])
|
||||
return meter
|
||||
}
|
||||
|
||||
function usePeakDrop () {
|
||||
const tickDown = 1
|
||||
const tickTime = 50
|
||||
const [state, setState] = useState({ max: 0, peak: 0 })
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setState(state => update(state, state.peak))
|
||||
}, tickTime)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return [state.max, updatePeak]
|
||||
|
||||
function update (state, peak) {
|
||||
const now = Date.now()
|
||||
const nextState = { ...state }
|
||||
if (peak !== state.peak) {
|
||||
nextState.peak = peak
|
||||
nextState.lastPeak = Date.now()
|
||||
}
|
||||
if (state.max < peak) {
|
||||
nextState.max = peak
|
||||
} else if (now - state.lastPeak > 1000) {
|
||||
if (now - state.lastPeak > 3000) {
|
||||
nextState.max = state.peak
|
||||
} else {
|
||||
nextState.max = Math.max(state.max - tickDown, state.peak)
|
||||
}
|
||||
}
|
||||
return nextState
|
||||
}
|
||||
|
||||
function updatePeak (peak) {
|
||||
setState(state => update(state, peak))
|
||||
}
|
||||
}
|
||||
|
||||
function toPx (val) {
|
||||
return value2px(val, SCALE_MIN, SCALE_MAX, 0, 100)
|
||||
}
|
||||
|
||||
function value2px (value, valueMin, valueMax, pxMin, pxMax) {
|
||||
if (value < valueMin) value = valueMin
|
||||
if (value > valueMax) value = valueMax
|
||||
if (isNaN(value) || value === null) value = valueMin
|
||||
|
||||
const valueWidth = valueMax - valueMin
|
||||
const pixelWidth = pxMax - pxMin
|
||||
const ratio = pixelWidth / valueWidth
|
||||
return ratio * (value - valueMin) + pxMin
|
||||
}
|
||||
|
||||
function style (color) {
|
||||
return { style: { fill: color } }
|
||||
}
|
||||
120
frontend/src/comp/Mixer.js
Normal file
120
frontend/src/comp/Mixer.js
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import React, { useReducer, useState } from 'react'
|
||||
import {
|
||||
Box, Flex, Badge, Text, Icon, Image, PseudoBox,
|
||||
FormControl, FormLabel, Input, FormHelperText,
|
||||
Slider, SliderTrack, SliderFilledTrack, SliderThumb
|
||||
} from '@chakra-ui/core'
|
||||
// import css from '@emotion/css'
|
||||
import { usePeak } from '../hooks/use-peak'
|
||||
import Button from './Button'
|
||||
import Meter from './Meter'
|
||||
|
||||
import { useOscReducer, useOscValue, useOscWritable } from '../hooks/use-osc-reducer'
|
||||
|
||||
export default function Mixer () {
|
||||
return (
|
||||
<Flex flex={1} h='100%' p='2'>
|
||||
<FaderChannel channel={0} />
|
||||
<FaderChannel channel={1} />
|
||||
<FaderChannel channel={2} />
|
||||
<FaderChannel channel={3} />
|
||||
<FaderChannel channel={4} />
|
||||
<FaderChannel channel={5} />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
// function reducer (state, action) {
|
||||
// console.log('reduce', state, action)
|
||||
// if (action.cmd === 'set') {
|
||||
// return { ...state, [action.key]: action.args[0] }
|
||||
// }
|
||||
// return state
|
||||
// }
|
||||
// const initialState = {
|
||||
// volume: 50,
|
||||
// pgm: 1,
|
||||
// pfl: 0
|
||||
// }
|
||||
|
||||
function OscMeter (props) {
|
||||
const { offset } = props
|
||||
const peak = usePeak(offset)
|
||||
console.log('USE PEAK', offset, peak)
|
||||
const peaks = [peak, peak]
|
||||
// const { address, combine } = props
|
||||
// const peak = useOscValue(address, -60, { single: true })
|
||||
// // TODO: Support stereo peaks?
|
||||
// const peaks = [peak, peak]
|
||||
// const peak = (peaks[0] + peaks[1]) / 2
|
||||
// console.log('render meter', peak)
|
||||
return (
|
||||
<>
|
||||
{peaks.map((peak, i) => (
|
||||
<Box w={2} key={i}>
|
||||
<Meter key={i} min={-60} max={0} value={peak} />
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FaderChannel (props) {
|
||||
const { channel, label } = props
|
||||
const bus = 1
|
||||
const peakOffset = ((bus + 2) * 8) + channel
|
||||
// const peak = usePeak(peakOffset)
|
||||
// const [state, dispatch] = useOscReducer(reducer, initialState, name)
|
||||
const volumeAddress = '/mixer/bus/1/ch/' + channel + '/volume'
|
||||
const [volume, setVolume] = useOscWritable(volumeAddress, 0, { single: true })
|
||||
const [pgm, setPgm] = useOscWritable('/mixer/bus/1/ch/' + channel + '/on', 0, { single: true })
|
||||
const [pfl, setPfl] = useOscWritable('/mixer/bus/2/ch/' + channel + '/on', 0, { single: true })
|
||||
|
||||
console.log('render fc', { volume, pgm, pfl })
|
||||
const peakAddress = '/mixer/bus/1/ch/' + channel + '/level'
|
||||
// const [value, setValue] = useState(0)
|
||||
return (
|
||||
<Flex direction='column' w={16} pr={2} mr={2} overflow='hidden'>
|
||||
<Text display='block' fontSize='xs' fontWeight='bold' color='#ddd'>{label}</Text>
|
||||
<Flex flex={1}>
|
||||
// <OscMeter address={peakAddress} channel={0} />
|
||||
<OscMeter offset={peakOffset} />
|
||||
</Flex>
|
||||
<Button onClick={e => setPgm(pgm => pgm ? 0 : 1)} active={!!pgm} mx='auto' mt={1} square size={12} variant='green'>ON</Button>
|
||||
<Button onClick={e => setPfl(pfl => pfl ? 0 : 1)} active={!!pfl} mx='auto' mt={1} square size={12} variant='blue'>PFL</Button>
|
||||
<Fader onChange={setVolume} value={volume} />
|
||||
<Text textAlign='center' display='block' fontSize='xs' fontWeight='bold' color='#ddd'>{label}</Text>
|
||||
</Flex>
|
||||
)
|
||||
|
||||
// function onFaderChange (value) {
|
||||
// // setValue(value)
|
||||
// // dispatch({ address: address + '/volume', args: [value] })
|
||||
// dispatch({ cmd: 'volume', args: [parseFloat(value)] })
|
||||
// }
|
||||
// function onBusChange (name) {
|
||||
// return function onClick (e) {
|
||||
// console.log('dipatch', name, state)
|
||||
// const intValue = state[name] ? 0 : 1
|
||||
// dispatch({ cmd: name, args: [intValue] })
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
function Fader (props) {
|
||||
return (
|
||||
<Slider
|
||||
color='pink'
|
||||
defaultValue={0}
|
||||
min={-70}
|
||||
max={4}
|
||||
step={0.2}
|
||||
orientation='vertical'
|
||||
{...props}
|
||||
>
|
||||
<SliderTrack />
|
||||
<SliderFilledTrack />
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
)
|
||||
}
|
||||
42
frontend/src/comp/Pane.js
Normal file
42
frontend/src/comp/Pane.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React, { useRef } from 'react'
|
||||
import { css, Box, PseudoBox, Stack } from '@chakra-ui/core'
|
||||
|
||||
export function Pane (props) {
|
||||
const { children, ...rest } = props
|
||||
return (
|
||||
<PseudoBox
|
||||
bg='black'
|
||||
display='flex'
|
||||
alignItems='stretch'
|
||||
flex={1}
|
||||
position='relative'
|
||||
border='paneBorder'
|
||||
_focusWithin={{
|
||||
border: 'paneBorderFocus'
|
||||
}}
|
||||
p={2}
|
||||
{...rest}
|
||||
>
|
||||
<Box
|
||||
position='absolute'
|
||||
left='0'
|
||||
right='0'
|
||||
top='0'
|
||||
bottom='0'
|
||||
overflow='auto'
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</PseudoBox>
|
||||
)
|
||||
}
|
||||
|
||||
export function Container (props) {
|
||||
return (
|
||||
<Stack
|
||||
flex={1}
|
||||
{...props}
|
||||
alignItems='stretch'
|
||||
/>
|
||||
)
|
||||
}
|
||||
113
frontend/src/comp/Player.js
Normal file
113
frontend/src/comp/Player.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import React, { useReducer, useState, useEffect } from 'react'
|
||||
import {
|
||||
Box, Flex, Input
|
||||
} from '@chakra-ui/core'
|
||||
import { useForm } from 'react-hook-form'
|
||||
// import css from '@emotion/css'
|
||||
import Button from './Button'
|
||||
import Bar from './Bar'
|
||||
|
||||
import { useOscReducer } from '../hooks/use-osc-reducer'
|
||||
|
||||
const initialState = { play: 'stop' }
|
||||
|
||||
function reducer (state, action) {
|
||||
const x = s => ({ ...state, ...s })
|
||||
const { cmd, args, key } = action
|
||||
if (cmd === 'set') {
|
||||
return x({ [key]: args[0] })
|
||||
}
|
||||
if (cmd === 'play') {
|
||||
return x({ play: 'play' })
|
||||
}
|
||||
if (cmd === 'pause') {
|
||||
if (state.play === 'stop') return state
|
||||
return x({ play: 'pause' })
|
||||
}
|
||||
if (cmd === 'playpause') {
|
||||
if (state.play === 'stop') return x({ play: 'play' })
|
||||
if (state.play === 'pause') return x({ play: 'play' })
|
||||
return x({ play: 'pause' })
|
||||
}
|
||||
if (cmd === 'stop') {
|
||||
return x({ play: 'stop' })
|
||||
}
|
||||
if (cmd === 'fullscreen') {
|
||||
return x({ fullscreen: !state.fullscreen })
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export default function Player (props) {
|
||||
const { label = 'Player', name = '/player/default' } = props
|
||||
const [state, dispatch] = useOscReducer(reducer, initialState, name)
|
||||
const { handleSubmit, register, errors } = useForm()
|
||||
let style = {}
|
||||
if (state.fullscreen) {
|
||||
style = {
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
zIndex: 1000
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Flex
|
||||
height='100%'
|
||||
direction='column'
|
||||
bg='black'
|
||||
contentAlign='stretch'
|
||||
style={style}
|
||||
>
|
||||
<Bar>
|
||||
<Bar.Element>{label}</Bar.Element>
|
||||
<Bar.Element>{state.play}</Bar.Element>
|
||||
</Bar>
|
||||
<Box flex={1} />
|
||||
<Flex as='form' onSubmit={handleSubmit(onAdd)} p={2}>
|
||||
<Input name='url' placeholder='Enter URL to play' ref={register()} />
|
||||
<Button>Add</Button>
|
||||
</Flex>
|
||||
<Flex p={2}>
|
||||
<PlayerButton state={state.play} dispatch={dispatch} action='play' active='play'>
|
||||
Play
|
||||
</PlayerButton>
|
||||
<PlayerButton variant='yellow' state={state.play} dispatch={dispatch} action='pause' active='pause'>
|
||||
Pause
|
||||
</PlayerButton>
|
||||
<PlayerButton variant='red' state={state.play} dispatch={dispatch} action='stop' active='stop'>
|
||||
Stop
|
||||
</PlayerButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
|
||||
function onAdd (values) {
|
||||
console.log('add', values)
|
||||
dispatch({ cmd: 'load', args: [values.url] })
|
||||
// e.preventDefault()
|
||||
// console.log(e.target.value)
|
||||
// const url = e.target.name.value
|
||||
// console.log(values)
|
||||
}
|
||||
|
||||
// <Button square onClick={_e => dispatch('fullscreen')} active={state.fullscreen}>Fullscreen</Button>
|
||||
// <PlayerButton state={state.play} dispatch={dispatch} action='playpause' active='play'>
|
||||
// Playpause
|
||||
// </PlayerButton>
|
||||
}
|
||||
|
||||
function PlayerButton (props) {
|
||||
let { action, dispatch, active, state, ...other } = props
|
||||
if (action && dispatch && !other.onClick) {
|
||||
other.onClick = _e => dispatch({ cmd: action })
|
||||
}
|
||||
if (typeof active !== 'boolean' && active) {
|
||||
active = state === active
|
||||
}
|
||||
return (
|
||||
<Button square variant='green' mr={2} size={16} active={active} {...other} />
|
||||
)
|
||||
}
|
||||
108
frontend/src/hooks/use-osc-reducer.js
Normal file
108
frontend/src/hooks/use-osc-reducer.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { useState, useReducer, useEffect } from 'react'
|
||||
import osc from '../lib/osc'
|
||||
import Debug from 'debug'
|
||||
|
||||
// const STATE = '/s'
|
||||
// const COMMAND = '/c'
|
||||
|
||||
const debug = Debug('ui:osc:reducer')
|
||||
|
||||
/**
|
||||
* Listen on a OSC value change over time.
|
||||
*/
|
||||
export function useOscValue (address, defaultValue, opts = {}) {
|
||||
// TODO: Global store.
|
||||
const [state, setState] = useState(defaultValue)
|
||||
useEffect(() => {
|
||||
// const stateAddress = STATE + address
|
||||
// console.log('useOscValue init', stateAddress)
|
||||
const unwatch = osc.on(address, message => {
|
||||
const { address, args } = message
|
||||
if (opts.single) setState(args[0])
|
||||
else setState(args)
|
||||
// setState(state => ({ ...state, [address]: args }))
|
||||
})
|
||||
return unwatch
|
||||
}, [address])
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to an OSC value.
|
||||
*/
|
||||
export function useOscWritable (address, defaultValue, opts = {}) {
|
||||
const state = useOscValue(address, defaultValue, opts)
|
||||
return [state, setState]
|
||||
|
||||
function setState (nextValue) {
|
||||
if (typeof nextValue === 'function') nextValue = nextValue(state)
|
||||
// const commandAddress = COMMAND + address
|
||||
// const parts = address.split('/')
|
||||
// const last = parts.pop()
|
||||
// const cmdAddress = [...parts, 'cmd', last].join('/')
|
||||
console.log('useOscWritable write', address, nextValue)
|
||||
osc.send(address, nextValue)
|
||||
}
|
||||
}
|
||||
|
||||
export function useOscReducer (reducer, initialState, prefix = '') {
|
||||
const [state, localDispatch] = useReducer(reducer, initialState)
|
||||
|
||||
const prefixLen = prefix.split('/').length
|
||||
|
||||
// Listen for all state change messages and dispatch into the reducer.
|
||||
useEffect(() => {
|
||||
const wildcardAddress = prefix + '/*'
|
||||
const unwatch = osc.on(wildcardAddress, message => {
|
||||
const { address, args } = message
|
||||
const key = address.split('/').slice(prefixLen + 1).join('/')
|
||||
localDispatch({ cmd: 'set', address: key, args })
|
||||
})
|
||||
return unwatch
|
||||
})
|
||||
|
||||
return [state, dispatch]
|
||||
|
||||
function dispatch (action) {
|
||||
console.log('useOscReducer dispatch', action)
|
||||
send(action)
|
||||
}
|
||||
|
||||
function send (action) {
|
||||
const { address, args } = action
|
||||
const fullAddress = COMMAND + prefix + address
|
||||
osc.send(fullAddress, args)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Broken, fix address paths.
|
||||
export function useOscReducerOld (reducer, initialState, prefix) {
|
||||
const [state, localDispatch] = useReducer(reducer, initialState)
|
||||
|
||||
// const [remoteState, remoteDispatch] = useReducer(reducer, initialState)
|
||||
//
|
||||
useEffect(() => {
|
||||
const path = prefix + '/state/*'
|
||||
const unwatch = osc.on(path, message => {
|
||||
const { address, args } = message
|
||||
// console.log('RECVV!!!!', message)
|
||||
const key = address.split('/').pop()
|
||||
localDispatch({ cmd: 'set', key, args })
|
||||
})
|
||||
return unwatch
|
||||
}, [])
|
||||
|
||||
return [state, dispatch]
|
||||
|
||||
function dispatch (action) {
|
||||
console.log('dispatch', action)
|
||||
send(action)
|
||||
// localDispatch(action)
|
||||
}
|
||||
|
||||
function send (action) {
|
||||
const { cmd, args } = action
|
||||
const address = [prefix, 'cmd', cmd].join('/')
|
||||
osc.send(address, args)
|
||||
}
|
||||
}
|
||||
47
frontend/src/hooks/use-peak.js
Normal file
47
frontend/src/hooks/use-peak.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import osc from '../lib/osc'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
class PeakStore {
|
||||
constructor () {
|
||||
this.watchers = []
|
||||
}
|
||||
watch (fn) {
|
||||
this.watchers.push(fn)
|
||||
return () => (this.watchers = this.watchers.filter(w => w !== fn))
|
||||
}
|
||||
set (val) {
|
||||
this.val = val
|
||||
for (const watcher of this.watchers) {
|
||||
watcher(val)
|
||||
}
|
||||
}
|
||||
get () {
|
||||
return this.val
|
||||
}
|
||||
}
|
||||
|
||||
const peaks = new PeakStore()
|
||||
|
||||
osc.on('/peaks', message => {
|
||||
const { args } = message
|
||||
const buf = args[0]
|
||||
const nextPeaks = {}
|
||||
for (let offset = 0; offset < 48; offset++) {
|
||||
let pos = offset * 2
|
||||
let val = buf[pos] * 256 + buf[pos + 1]
|
||||
val = (val / 800) - 70
|
||||
nextPeaks[offset] = val
|
||||
}
|
||||
console.log('RECV PEAKS', nextPeaks)
|
||||
peaks.set(nextPeaks)
|
||||
})
|
||||
|
||||
export function usePeak (offset) {
|
||||
const [peak, setPeak] = useState(-70)
|
||||
useEffect(() => {
|
||||
const unwatch = peaks.watch(peaks => {
|
||||
setPeak(peaks[offset])
|
||||
})
|
||||
})
|
||||
return peak
|
||||
}
|
||||
9
frontend/src/index.js
Normal file
9
frontend/src/index.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
|
||||
import App from './App'
|
||||
|
||||
const el = document.createElement('div')
|
||||
document.body.appendChild(el)
|
||||
|
||||
ReactDOM.render(<App />, el)
|
||||
126
frontend/src/lib/osc-client-plugin.js
Normal file
126
frontend/src/lib/osc-client-plugin.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// eslint-disable-next-line no-undef
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket'
|
||||
|
||||
/**
|
||||
* Status flags
|
||||
* @private
|
||||
*/
|
||||
const STATUS = {
|
||||
IS_NOT_INITIALIZED: -1,
|
||||
IS_CONNECTING: 0,
|
||||
IS_OPEN: 1,
|
||||
IS_CLOSING: 2,
|
||||
IS_CLOSED: 3,
|
||||
}
|
||||
|
||||
const DEFAULT_URL = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}`
|
||||
|
||||
/**
|
||||
* Default options
|
||||
* @private
|
||||
*/
|
||||
const defaultOptions = {
|
||||
url: DEFAULT_URL,
|
||||
path: null
|
||||
}
|
||||
|
||||
/**
|
||||
* OSC plugin for a Websocket client running in node or browser context
|
||||
*/
|
||||
export default class WebsocketClientPlugin {
|
||||
/**
|
||||
* Create an OSC WebsocketClientPlugin instance with given options.
|
||||
* Defaults to *localhost:8080* for connecting to a Websocket server
|
||||
* @param {object} [options] Custom options
|
||||
* @param {string} [options.host='localhost'] Hostname of Websocket server
|
||||
* @param {number} [options.port=8080] Port of Websocket server
|
||||
* @param {boolean} [options.secure=false] Use wss:// for secure connections
|
||||
*
|
||||
* @example
|
||||
* const plugin = new OSC.WebsocketClientPlugin({ port: 9912 })
|
||||
* const osc = new OSC({ plugin: plugin })
|
||||
*/
|
||||
constructor(customOptions) {
|
||||
this.opts = { ...defaultOptions, ...customOptions }
|
||||
this.socket = null
|
||||
this.socketStatus = STATUS.IS_NOT_INITIALIZED
|
||||
this.notify = () => {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to hook into osc library's
|
||||
* EventHandler notify method
|
||||
* @param {function} fn Notify callback
|
||||
* @private
|
||||
*/
|
||||
registerNotify (fn) {
|
||||
this.notify = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current status of the connection
|
||||
* @return {number} Status identifier
|
||||
*/
|
||||
status () {
|
||||
return this.socketStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a Websocket server. Defaults to global options
|
||||
* @param {object} [customOptions] Custom options
|
||||
* @param {string} [customOptions.host] Hostname of Websocket server
|
||||
* @param {number} [customOptions.port] Port of Websocket server
|
||||
* @param {boolean} [customOptions.secure] Use wss:// for secure connections
|
||||
*/
|
||||
open (customOptions = {}) {
|
||||
const options = { ...this.opts, ...customOptions }
|
||||
let { url, path } = options
|
||||
|
||||
if (path) url = url + path
|
||||
console.log('WS CONNECT', url)
|
||||
this.socket = new ReconnectingWebSocket(url, null, { debug: true, reconnectInterval: 1000, autoOpen: true, binaryType: 'arraybuffer' })
|
||||
|
||||
this.socket.binaryType = 'arraybuffer'
|
||||
|
||||
// this.socketStatus = STATUS.IS_CONNECTING
|
||||
// register events
|
||||
this.socket.onopen = () => {
|
||||
console.log('WS OPEN')
|
||||
this.socketStatus = STATUS.IS_OPEN
|
||||
this.notify('open')
|
||||
}
|
||||
|
||||
this.socket.onclose = () => {
|
||||
this.socketStatus = STATUS.IS_CLOSED
|
||||
this.notify('close')
|
||||
}
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
this.notify('error', error)
|
||||
}
|
||||
|
||||
this.socket.onmessage = (message) => {
|
||||
this.notify(message.data, { url: this.opts.url })
|
||||
}
|
||||
|
||||
// this.socket.open()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Close Websocket
|
||||
*/
|
||||
close () {
|
||||
this.socketStatus = STATUS.IS_CLOSING
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an OSC Packet, Bundle or Message to Websocket server
|
||||
* @param {Uint8Array} binary Binary representation of OSC Packet
|
||||
*/
|
||||
send (binary) {
|
||||
console.log('!! SEND', binary)
|
||||
this.socket.send(binary)
|
||||
}
|
||||
}
|
||||
58
frontend/src/lib/osc.js
Normal file
58
frontend/src/lib/osc.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import OSC from 'osc-js'
|
||||
import Debug from 'debug'
|
||||
import Client from './osc-client-plugin'
|
||||
|
||||
const debug = Debug('osc')
|
||||
|
||||
const DEFAULT_OPTS = {
|
||||
plugin: new Client(),
|
||||
// path: '/ws',
|
||||
url: 'ws://localhost:8080/ws'
|
||||
// host: 'localhost',
|
||||
// host: window.location.hostname,
|
||||
// port: '18443/ws',
|
||||
// port: '8080'
|
||||
// secure: false
|
||||
}
|
||||
|
||||
export class OSCHandler {
|
||||
constructor (opts = {}) {
|
||||
this.opts = { ...DEFAULT_OPTS, ...opts }
|
||||
this.osc = new OSC({ plugin: this.opts.plugin })
|
||||
this.osc.on('*', (message) => {
|
||||
const { address, args } = message
|
||||
debug('RECV %s %o', address, args)
|
||||
})
|
||||
this.osc.on('error', (err) => {
|
||||
console.error('OSC error', err)
|
||||
})
|
||||
}
|
||||
|
||||
open () {
|
||||
this.osc.open(this.opts)
|
||||
}
|
||||
|
||||
on (address, fn) {
|
||||
const id = this.osc.on(address, fn)
|
||||
return () => this.osc.off(address, id)
|
||||
}
|
||||
|
||||
send (address, args = [], opts) {
|
||||
if (address instanceof OSC.Message || address instanceof OSC.Bundle || address instanceof OSC.Packet) {
|
||||
debug('SEND', address)
|
||||
return this.osc.send(address, args)
|
||||
} else if (typeof address === 'string') {
|
||||
debug('SEND', address, args)
|
||||
if (!Array.isArray(args)) args = [args]
|
||||
const message = new OSC.Message(address, ...args)
|
||||
return this.osc.send(message, opts)
|
||||
} else {
|
||||
throw new Error('Invalid arguments to osc.send()', address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const osc = new OSCHandler()
|
||||
osc.open()
|
||||
|
||||
export default osc
|
||||
67
frontend/src/theme.js
Normal file
67
frontend/src/theme.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { theme as chakra } from '@chakra-ui/core'
|
||||
|
||||
import { Input } from '@chakra-ui/core'
|
||||
Input.defaultProps = {
|
||||
...Input.defaultProps,
|
||||
borderRadius: 0,
|
||||
bg: '#444',
|
||||
color: '#eee',
|
||||
border: 'paneBorder',
|
||||
borderColor: 'inputBorder',
|
||||
borderWidth: '2px',
|
||||
_hover: {
|
||||
borderColor: 'inputBorderHover'
|
||||
},
|
||||
_focus: {
|
||||
boxShadow: 'inputFocus',
|
||||
border: 'inputFocus'
|
||||
},
|
||||
h: 12
|
||||
}
|
||||
|
||||
export default function studioxtheme (props = {}) {
|
||||
const theme = {
|
||||
...chakra,
|
||||
shadows: {
|
||||
inputFocus: '0 0 10px 4px rgba(255, 50, 200, 0.7)',
|
||||
},
|
||||
colors: {
|
||||
...chakra.colors,
|
||||
textBar: '#add',
|
||||
inputBorder: 'rgba(70, 100, 100, 0.5)',
|
||||
inputBorderHover: 'rgba(70, 100, 100, 1)',
|
||||
button: {
|
||||
redgreen: {
|
||||
on: chakra.colors.green['400'],
|
||||
off: chakra.colors.red['600']
|
||||
},
|
||||
blue: {
|
||||
on: '#00aaff',
|
||||
off: '#333'
|
||||
},
|
||||
green: {
|
||||
on: '#00cc44',
|
||||
off: '#333'
|
||||
},
|
||||
red: {
|
||||
on: '#cc3300',
|
||||
off: '#333'
|
||||
},
|
||||
yellow: {
|
||||
on: '#eedd00',
|
||||
off: '#333'
|
||||
}
|
||||
}
|
||||
},
|
||||
borders: {
|
||||
...chakra.borders,
|
||||
paneBorder: '4px solid rgba(0, 150, 150, 0.4)',
|
||||
paneBorderFocus: '4px solid rgba(0, 200, 200, 0.5)',
|
||||
barSeperator: '4px solid rgba(0, 150, 150, 0.4)',
|
||||
inputFocus: '2px solid magenta',
|
||||
inputHover: '2px solid rgba(255,255,255,0.3)'
|
||||
}
|
||||
}
|
||||
console.log(theme)
|
||||
return theme
|
||||
}
|
||||
11
frontend/static/index.html
Normal file
11
frontend/static/index.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>arsonar</title>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="bundle.css">
|
||||
</head>
|
||||
<body>
|
||||
<script src="bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue