|
|
@@ -1,16 +1,15 @@
|
|
|
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
|
|
-import io from 'socket.io-client'
|
|
|
import Typography from '@mui/material/Typography'
|
|
|
import Container from '@mui/material/Container'
|
|
|
import ToggleButton from '@mui/material/ToggleButton'
|
|
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
|
|
import {PB} from 'smartknobjs-proto'
|
|
|
import {VideoInfo} from './types'
|
|
|
-import {Card, CardContent} from '@mui/material'
|
|
|
+import {Button, CardActions, Paper} from '@mui/material'
|
|
|
import {exhaustiveCheck, findNClosest, lerp, NoUndefinedField} from './util'
|
|
|
import {groupBy, parseInt} from 'lodash'
|
|
|
-
|
|
|
-const socket = io()
|
|
|
+import _ from 'lodash'
|
|
|
+import {SmartKnobWebSerial} from 'smartknobjs-webserial'
|
|
|
|
|
|
const MIN_ZOOM = 0.01
|
|
|
const MAX_ZOOM = 60
|
|
|
@@ -40,8 +39,7 @@ export type AppProps = {
|
|
|
info: VideoInfo
|
|
|
}
|
|
|
export const App: React.FC<AppProps> = ({info}) => {
|
|
|
- const [isConnected, setIsConnected] = useState(socket.connected)
|
|
|
-
|
|
|
+ const [smartKnob, setSmartKnob] = useState<SmartKnobWebSerial | null>(null)
|
|
|
const [smartKnobState, setSmartKnobState] = useState<NoUndefinedField<PB.ISmartKnobState>>(
|
|
|
PB.SmartKnobState.toObject(PB.SmartKnobState.create({config: PB.SmartKnobConfig.create()}), {
|
|
|
defaults: true,
|
|
|
@@ -65,8 +63,9 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
})
|
|
|
useEffect(() => {
|
|
|
console.log('send config', smartKnobConfig)
|
|
|
- socket.emit('set_config', smartKnobConfig)
|
|
|
+ smartKnob?.sendConfig(PB.SmartKnobConfig.create(smartKnobConfig))
|
|
|
}, [
|
|
|
+ smartKnob,
|
|
|
smartKnobConfig.position,
|
|
|
smartKnobConfig.subPositionUnit,
|
|
|
smartKnobConfig.positionNonce,
|
|
|
@@ -85,7 +84,7 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
currentFrame: 0,
|
|
|
})
|
|
|
const [interfaceState, setInterfaceState] = useState<InterfaceState>({
|
|
|
- zoomTimelinePixelsPerFrame: 0.1,
|
|
|
+ zoomTimelinePixelsPerFrame: 0.3,
|
|
|
})
|
|
|
|
|
|
const totalPositions = Math.ceil(
|
|
|
@@ -104,10 +103,17 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
const position = (playbackState.currentFrame * smartKnobConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION
|
|
|
return Math.round(position)
|
|
|
}, [playbackState.currentFrame, smartKnobConfig.zoomTimelinePixelsPerFrame])
|
|
|
- const nClosestMemo = useMemo(() => {
|
|
|
- return findNClosest(Object.keys(detentPositions).map(parseInt), scrollPositionWholeMemo, 5).sort(
|
|
|
+ const [nClosest, setNClosest] = useState<Array<number>>([])
|
|
|
+ useEffect(() => {
|
|
|
+ const calculated = findNClosest(Object.keys(detentPositions).map(parseInt), scrollPositionWholeMemo, 5).sort(
|
|
|
(a, b) => a - b,
|
|
|
)
|
|
|
+ setNClosest((cur) => {
|
|
|
+ if (_.isEqual(cur, calculated)) {
|
|
|
+ return cur
|
|
|
+ }
|
|
|
+ return calculated
|
|
|
+ })
|
|
|
}, [scrollPositionWholeMemo])
|
|
|
|
|
|
const changeMode = useCallback(
|
|
|
@@ -119,11 +125,11 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
const positionWhole = Math.round(position)
|
|
|
const subPositionUnit = position - positionWhole
|
|
|
return {
|
|
|
- position,
|
|
|
+ position: positionWhole,
|
|
|
subPositionUnit,
|
|
|
positionNonce: (curConfig.positionNonce + 1) % 256,
|
|
|
minPosition: 0,
|
|
|
- maxPosition: Math.trunc(
|
|
|
+ maxPosition: Math.round(
|
|
|
((info.totalFrames - 1) * curConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
|
|
|
),
|
|
|
positionWidthRadians: (8 * Math.PI) / 180,
|
|
|
@@ -140,7 +146,7 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
} else if (newMode === Mode.Frames) {
|
|
|
setSmartKnobConfig((curConfig) => {
|
|
|
return {
|
|
|
- position: playbackState.currentFrame,
|
|
|
+ position: Math.floor(playbackState.currentFrame),
|
|
|
subPositionUnit: 0,
|
|
|
positionNonce: (curConfig.positionNonce + 1) % 256,
|
|
|
minPosition: 0,
|
|
|
@@ -164,10 +170,10 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
positionNonce: (curConfig.positionNonce + 1) % 256,
|
|
|
minPosition: playbackState.currentFrame === 0 ? 0 : -6,
|
|
|
maxPosition: playbackState.currentFrame === info.totalFrames - 1 ? 0 : 6,
|
|
|
- positionWidthRadians: (60 * Math.PI) / 180,
|
|
|
+ positionWidthRadians: (30 * Math.PI) / 180,
|
|
|
detentStrengthUnit: 1,
|
|
|
endstopStrengthUnit: 1,
|
|
|
- snapPoint: 0.55,
|
|
|
+ snapPoint: 0.5,
|
|
|
text: Mode.Speed,
|
|
|
detentPositions: [],
|
|
|
snapPointBias: 0.4,
|
|
|
@@ -181,6 +187,9 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
},
|
|
|
[detentPositions, info.totalFrames, playbackState],
|
|
|
)
|
|
|
+ useEffect(() => {
|
|
|
+ changeMode(Mode.Scroll)
|
|
|
+ }, [])
|
|
|
|
|
|
useEffect(() => {
|
|
|
if (smartKnobState.config.text === '') {
|
|
|
@@ -222,7 +231,7 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
subPositionUnit,
|
|
|
positionNonce: (curConfig.positionNonce + 1) % 256,
|
|
|
minPosition: 0,
|
|
|
- maxPosition: Math.trunc(
|
|
|
+ maxPosition: Math.round(
|
|
|
((info.totalFrames - 1) * interfaceState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
|
|
|
),
|
|
|
zoomTimelinePixelsPerFrame: interfaceState.zoomTimelinePixelsPerFrame,
|
|
|
@@ -231,7 +240,7 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
return {
|
|
|
...curConfig,
|
|
|
...positionInfo,
|
|
|
- detentPositions: nClosestMemo,
|
|
|
+ detentPositions: nClosest,
|
|
|
}
|
|
|
})
|
|
|
} else if (currentMode === Mode.Frames) {
|
|
|
@@ -268,7 +277,7 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
}
|
|
|
}, [
|
|
|
detentPositions,
|
|
|
- nClosestMemo,
|
|
|
+ nClosest,
|
|
|
info.totalFrames,
|
|
|
smartKnobState.config.text,
|
|
|
smartKnobState.currentPosition,
|
|
|
@@ -314,90 +323,102 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
}
|
|
|
}, [smartKnobState.config.text, isPlaying])
|
|
|
|
|
|
- // Socket.io subscription
|
|
|
- useEffect(() => {
|
|
|
- socket.on('connect', () => {
|
|
|
- setIsConnected(true)
|
|
|
- })
|
|
|
-
|
|
|
- socket.on('disconnect', () => {
|
|
|
- setIsConnected(false)
|
|
|
- })
|
|
|
-
|
|
|
- socket.on('state', (input: {pb: PB.SmartKnobState}) => {
|
|
|
- const {pb: state} = input
|
|
|
- const stateObj = PB.SmartKnobState.toObject(state, {
|
|
|
- defaults: true,
|
|
|
- }) as NoUndefinedField<PB.ISmartKnobState>
|
|
|
- setSmartKnobState(stateObj)
|
|
|
- })
|
|
|
- return () => {
|
|
|
- socket.off('connect')
|
|
|
- socket.off('disconnect')
|
|
|
- socket.off('state')
|
|
|
+ const connectToSerial = async () => {
|
|
|
+ try {
|
|
|
+ if (navigator.serial) {
|
|
|
+ const serialPort = await navigator.serial.requestPort({
|
|
|
+ filters: SmartKnobWebSerial.USB_DEVICE_FILTERS,
|
|
|
+ })
|
|
|
+ serialPort.addEventListener('disconnect', () => {
|
|
|
+ setSmartKnob(null)
|
|
|
+ })
|
|
|
+ const smartKnob = new SmartKnobWebSerial(serialPort, (message) => {
|
|
|
+ if (message.payload === 'smartknobState' && message.smartknobState !== null) {
|
|
|
+ const state = PB.SmartKnobState.create(message.smartknobState)
|
|
|
+ const stateObj = PB.SmartKnobState.toObject(state, {
|
|
|
+ defaults: true,
|
|
|
+ }) as NoUndefinedField<PB.ISmartKnobState>
|
|
|
+ setSmartKnobState(stateObj)
|
|
|
+ } else if (message.payload === 'log' && message.log !== null) {
|
|
|
+ console.log('LOG from smartknob', message.log?.msg)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ setSmartKnob(smartKnob)
|
|
|
+ await smartKnob.openAndLoop()
|
|
|
+ } else {
|
|
|
+ console.error('Web Serial API is not supported in this browser.')
|
|
|
+ setSmartKnob(null)
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Error with serial port:', error)
|
|
|
+ setSmartKnob(null)
|
|
|
}
|
|
|
- }, [])
|
|
|
+ }
|
|
|
+
|
|
|
return (
|
|
|
<>
|
|
|
- <Container component="main" maxWidth="md">
|
|
|
- <Card>
|
|
|
- <CardContent>
|
|
|
- <Typography component="h1" variant="h5">
|
|
|
- Video Playback Control Demo
|
|
|
- </Typography>
|
|
|
- {isConnected || (
|
|
|
- <Typography component="h6" variant="h6">
|
|
|
- [Not connected]
|
|
|
+ <Container component="main" maxWidth="lg">
|
|
|
+ <Paper variant="outlined" sx={{my: {xs: 3, md: 6}, p: {xs: 2, md: 3}}}>
|
|
|
+ <Typography component="h1" variant="h5">
|
|
|
+ Video Playback Control Demo
|
|
|
+ </Typography>
|
|
|
+ {smartKnob !== null ? (
|
|
|
+ <>
|
|
|
+ <ToggleButtonGroup
|
|
|
+ color="primary"
|
|
|
+ value={smartKnobConfig.text}
|
|
|
+ exclusive
|
|
|
+ onChange={(e, value: Mode | null) => {
|
|
|
+ if (value === null) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ changeMode(value)
|
|
|
+ }}
|
|
|
+ aria-label="Mode"
|
|
|
+ >
|
|
|
+ {Object.keys(Mode).map((mode) => (
|
|
|
+ <ToggleButton value={mode} key={mode}>
|
|
|
+ {mode}
|
|
|
+ </ToggleButton>
|
|
|
+ ))}
|
|
|
+ </ToggleButtonGroup>
|
|
|
+ <Typography>
|
|
|
+ Frame {Math.trunc(playbackState.currentFrame)} / {info.totalFrames - 1}
|
|
|
+ <br />
|
|
|
+ Speed {playbackState.speed}
|
|
|
</Typography>
|
|
|
- )}
|
|
|
- <ToggleButtonGroup
|
|
|
- color="primary"
|
|
|
- value={smartKnobConfig.text}
|
|
|
- exclusive
|
|
|
- onChange={(e, value: Mode | null) => {
|
|
|
- if (value === null) {
|
|
|
- return
|
|
|
- }
|
|
|
- changeMode(value)
|
|
|
- }}
|
|
|
- aria-label="Mode"
|
|
|
- >
|
|
|
- {Object.keys(Mode).map((mode) => (
|
|
|
- <ToggleButton value={mode} key={mode}>
|
|
|
- {mode}
|
|
|
- </ToggleButton>
|
|
|
- ))}
|
|
|
- </ToggleButtonGroup>
|
|
|
+ <Timeline
|
|
|
+ info={info}
|
|
|
+ currentFrame={playbackState.currentFrame}
|
|
|
+ zoomTimelinePixelsPerFrame={interfaceState.zoomTimelinePixelsPerFrame}
|
|
|
+ adjustZoom={(factor) => {
|
|
|
+ setInterfaceState((cur) => {
|
|
|
+ const newZoom = Math.min(
|
|
|
+ Math.max(cur.zoomTimelinePixelsPerFrame * factor, MIN_ZOOM),
|
|
|
+ MAX_ZOOM,
|
|
|
+ )
|
|
|
+ console.log(factor, newZoom)
|
|
|
+ return {
|
|
|
+ ...cur,
|
|
|
+ zoomTimelinePixelsPerFrame: newZoom,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </>
|
|
|
+ ) : navigator.serial ? (
|
|
|
+ <CardActions>
|
|
|
+ <Button onClick={connectToSerial} variant="contained">
|
|
|
+ Connect via Web Serial
|
|
|
+ </Button>
|
|
|
+ </CardActions>
|
|
|
+ ) : (
|
|
|
<Typography>
|
|
|
- Frame {Math.trunc(playbackState.currentFrame)} / {info.totalFrames - 1}
|
|
|
- <br />
|
|
|
- Speed {playbackState.speed}
|
|
|
+ Sorry, Web Serial API isn't available in your browser. Try the latest version of Chrome.
|
|
|
</Typography>
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
- <Timeline
|
|
|
- info={info}
|
|
|
- currentFrame={playbackState.currentFrame}
|
|
|
- zoomTimelinePixelsPerFrame={interfaceState.zoomTimelinePixelsPerFrame}
|
|
|
- adjustZoom={(factor) => {
|
|
|
- setInterfaceState((cur) => {
|
|
|
- const newZoom = Math.min(
|
|
|
- Math.max(cur.zoomTimelinePixelsPerFrame * factor, MIN_ZOOM),
|
|
|
- MAX_ZOOM,
|
|
|
- )
|
|
|
- console.log(factor, newZoom)
|
|
|
- return {
|
|
|
- ...cur,
|
|
|
- zoomTimelinePixelsPerFrame: newZoom,
|
|
|
- }
|
|
|
- })
|
|
|
- }}
|
|
|
- />
|
|
|
- <Card>
|
|
|
- <CardContent>
|
|
|
- <div>{JSON.stringify(smartKnobConfig)}</div>
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
+ )}
|
|
|
+ <pre>{JSON.stringify(smartKnobConfig, undefined, 2)}</pre>
|
|
|
+ </Paper>
|
|
|
</Container>
|
|
|
</>
|
|
|
)
|