|
|
@@ -1,4 +1,4 @@
|
|
|
-import React, {useEffect, useMemo, useRef, useState} from 'react'
|
|
|
+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'
|
|
|
@@ -7,7 +7,7 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
|
|
import {PB} from 'smartknobjs-proto'
|
|
|
import {VideoInfo} from './types'
|
|
|
import {Card, CardContent} from '@mui/material'
|
|
|
-import {exhaustiveCheck, findNClosest, INT32_MIN, lerp, NoUndefinedField} from './util'
|
|
|
+import {exhaustiveCheck, findNClosest, lerp, NoUndefinedField} from './util'
|
|
|
import {groupBy, parseInt} from 'lodash'
|
|
|
|
|
|
const socket = io()
|
|
|
@@ -15,7 +15,7 @@ const socket = io()
|
|
|
const MIN_ZOOM = 0.01
|
|
|
const MAX_ZOOM = 60
|
|
|
|
|
|
-const PIXELS_PER_POSITION = 10
|
|
|
+const PIXELS_PER_POSITION = 5
|
|
|
|
|
|
enum Mode {
|
|
|
Scroll = 'Scroll',
|
|
|
@@ -23,10 +23,16 @@ enum Mode {
|
|
|
Speed = 'Speed',
|
|
|
}
|
|
|
|
|
|
-type State = {
|
|
|
- mode: Mode
|
|
|
- playbackSpeed: number
|
|
|
+type PlaybackState = {
|
|
|
+ speed: number
|
|
|
currentFrame: number
|
|
|
+}
|
|
|
+
|
|
|
+type InterfaceState = {
|
|
|
+ zoomTimelinePixelsPerFrame: number
|
|
|
+}
|
|
|
+
|
|
|
+type Config = NoUndefinedField<PB.ISmartKnobConfig> & {
|
|
|
zoomTimelinePixelsPerFrame: number
|
|
|
}
|
|
|
|
|
|
@@ -35,197 +41,283 @@ export type AppProps = {
|
|
|
}
|
|
|
export const App: React.FC<AppProps> = ({info}) => {
|
|
|
const [isConnected, setIsConnected] = useState(socket.connected)
|
|
|
- const [state, setState] = useState<NoUndefinedField<PB.ISmartKnobState>>(
|
|
|
+
|
|
|
+ const [smartKnobState, setSmartKnobState] = useState<NoUndefinedField<PB.ISmartKnobState>>(
|
|
|
PB.SmartKnobState.toObject(PB.SmartKnobState.create({config: PB.SmartKnobConfig.create()}), {
|
|
|
defaults: true,
|
|
|
}) as NoUndefinedField<PB.ISmartKnobState>,
|
|
|
)
|
|
|
- const [derivedState, setDerivedState] = useState<State>({
|
|
|
- mode: Mode.Scroll,
|
|
|
- playbackSpeed: 0,
|
|
|
+ const [smartKnobConfig, setSmartKnobConfig] = useState<Config>({
|
|
|
+ position: 0,
|
|
|
+ subPositionUnit: 0,
|
|
|
+ positionNonce: Math.floor(Math.random() * 255),
|
|
|
+ minPosition: 0,
|
|
|
+ maxPosition: 0,
|
|
|
+ positionWidthRadians: (15 * Math.PI) / 180,
|
|
|
+ detentStrengthUnit: 0,
|
|
|
+ endstopStrengthUnit: 1,
|
|
|
+ snapPoint: 0.7,
|
|
|
+ text: Mode.Scroll,
|
|
|
+ detentPositions: [],
|
|
|
+ snapPointBias: 0,
|
|
|
+
|
|
|
+ zoomTimelinePixelsPerFrame: 0.1,
|
|
|
+ })
|
|
|
+ useEffect(() => {
|
|
|
+ console.log('send config', smartKnobConfig)
|
|
|
+ socket.emit('set_config', smartKnobConfig)
|
|
|
+ }, [
|
|
|
+ smartKnobConfig.position,
|
|
|
+ smartKnobConfig.subPositionUnit,
|
|
|
+ smartKnobConfig.positionNonce,
|
|
|
+ smartKnobConfig.minPosition,
|
|
|
+ smartKnobConfig.maxPosition,
|
|
|
+ smartKnobConfig.positionWidthRadians,
|
|
|
+ smartKnobConfig.detentStrengthUnit,
|
|
|
+ smartKnobConfig.endstopStrengthUnit,
|
|
|
+ smartKnobConfig.snapPoint,
|
|
|
+ smartKnobConfig.text,
|
|
|
+ smartKnobConfig.detentPositions,
|
|
|
+ smartKnobConfig.snapPointBias,
|
|
|
+ ])
|
|
|
+ const [playbackState, setPlaybackState] = useState<PlaybackState>({
|
|
|
+ speed: 0,
|
|
|
currentFrame: 0,
|
|
|
+ })
|
|
|
+ const [interfaceState, setInterfaceState] = useState<InterfaceState>({
|
|
|
zoomTimelinePixelsPerFrame: 0.1,
|
|
|
})
|
|
|
|
|
|
- useMemo(() => {
|
|
|
- setDerivedState((cur) => {
|
|
|
- const modeText = state.config.text
|
|
|
- if (modeText === Mode.Scroll) {
|
|
|
- const rawFrame = Math.trunc(
|
|
|
- ((state.currentPosition + state.subPositionUnit) * PIXELS_PER_POSITION) /
|
|
|
- cur.zoomTimelinePixelsPerFrame,
|
|
|
- )
|
|
|
- return {
|
|
|
- mode: Mode.Scroll,
|
|
|
- playbackSpeed: 0,
|
|
|
- currentFrame: Math.min(Math.max(rawFrame, 0), info.totalFrames - 1),
|
|
|
- zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
|
|
|
- }
|
|
|
- } else if (modeText === Mode.Frames) {
|
|
|
- return {
|
|
|
- mode: Mode.Frames,
|
|
|
- playbackSpeed: 0,
|
|
|
- currentFrame: state.currentPosition ?? 0,
|
|
|
- zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
|
|
|
- }
|
|
|
- } else if (modeText === Mode.Speed) {
|
|
|
- const normalizedWholeValue = state.currentPosition
|
|
|
- const normalizedFractional =
|
|
|
- Math.sign(state.subPositionUnit) *
|
|
|
- lerp(state.subPositionUnit * Math.sign(state.subPositionUnit), 0.1, 0.9, 0, 1)
|
|
|
- const normalized = normalizedWholeValue + normalizedFractional
|
|
|
- const speed = Math.sign(normalized) * Math.pow(2, Math.abs(normalized) - 1)
|
|
|
- return {
|
|
|
- mode: Mode.Speed,
|
|
|
- playbackSpeed: speed,
|
|
|
- currentFrame: cur.currentFrame,
|
|
|
- zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
|
|
|
- }
|
|
|
- }
|
|
|
- return cur
|
|
|
- })
|
|
|
- }, [state.config.text, state.currentPosition, state.subPositionUnit])
|
|
|
-
|
|
|
- const totalPositions = Math.ceil((info.totalFrames * derivedState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION)
|
|
|
+ const totalPositions = Math.ceil(
|
|
|
+ (info.totalFrames * smartKnobConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
|
|
|
+ )
|
|
|
const detentPositions = useMemo(() => {
|
|
|
// Always include the first and last positions at detents
|
|
|
const positionsToFrames = groupBy([0, ...info.boundaryFrames, info.totalFrames - 1], (frame) =>
|
|
|
- Math.round((frame * derivedState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION),
|
|
|
+ Math.round((frame * smartKnobConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION),
|
|
|
)
|
|
|
console.log(JSON.stringify(positionsToFrames))
|
|
|
return positionsToFrames
|
|
|
- }, [info.boundaryFrames, totalPositions, derivedState.zoomTimelinePixelsPerFrame])
|
|
|
-
|
|
|
- // Continuous config updates for scrolling, to update detent positions
|
|
|
- useMemo(() => {
|
|
|
- if (derivedState.mode === Mode.Scroll) {
|
|
|
- const config = PB.SmartKnobConfig.create({
|
|
|
- position: INT32_MIN,
|
|
|
- minPosition: 0,
|
|
|
- maxPosition: totalPositions - 1,
|
|
|
- positionWidthRadians: (8 * Math.PI) / 180,
|
|
|
- detentStrengthUnit: 2.5,
|
|
|
- endstopStrengthUnit: 1,
|
|
|
- snapPoint: 0.7,
|
|
|
- text: Mode.Scroll,
|
|
|
- detentPositions: findNClosest(Object.keys(detentPositions).map(parseInt), state.currentPosition, 5),
|
|
|
- snapPointBias: 0,
|
|
|
- })
|
|
|
- socket.emit('set_config', config)
|
|
|
+ }, [info.boundaryFrames, info.totalFrames, totalPositions, smartKnobConfig.zoomTimelinePixelsPerFrame])
|
|
|
+
|
|
|
+ const scrollPositionWholeMemo = useMemo(() => {
|
|
|
+ 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(
|
|
|
+ (a, b) => a - b,
|
|
|
+ )
|
|
|
+ }, [scrollPositionWholeMemo])
|
|
|
+
|
|
|
+ const changeMode = useCallback(
|
|
|
+ (newMode: Mode) => {
|
|
|
+ if (newMode === Mode.Scroll) {
|
|
|
+ setSmartKnobConfig((curConfig) => {
|
|
|
+ const position =
|
|
|
+ (playbackState.currentFrame * curConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION
|
|
|
+ const positionWhole = Math.round(position)
|
|
|
+ const subPositionUnit = position - positionWhole
|
|
|
+ return {
|
|
|
+ position,
|
|
|
+ subPositionUnit,
|
|
|
+ positionNonce: (curConfig.positionNonce + 1) % 256,
|
|
|
+ minPosition: 0,
|
|
|
+ maxPosition: Math.trunc(
|
|
|
+ ((info.totalFrames - 1) * curConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
|
|
|
+ ),
|
|
|
+ positionWidthRadians: (8 * Math.PI) / 180,
|
|
|
+ detentStrengthUnit: 3,
|
|
|
+ endstopStrengthUnit: 1,
|
|
|
+ snapPoint: 0.7,
|
|
|
+ text: Mode.Scroll,
|
|
|
+ detentPositions: findNClosest(Object.keys(detentPositions).map(parseInt), position, 5),
|
|
|
+ snapPointBias: 0,
|
|
|
+
|
|
|
+ zoomTimelinePixelsPerFrame: curConfig.zoomTimelinePixelsPerFrame,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } else if (newMode === Mode.Frames) {
|
|
|
+ setSmartKnobConfig((curConfig) => {
|
|
|
+ return {
|
|
|
+ position: playbackState.currentFrame,
|
|
|
+ subPositionUnit: 0,
|
|
|
+ positionNonce: (curConfig.positionNonce + 1) % 256,
|
|
|
+ minPosition: 0,
|
|
|
+ maxPosition: info.totalFrames - 1,
|
|
|
+ positionWidthRadians: (1.8 * Math.PI) / 180,
|
|
|
+ detentStrengthUnit: 1,
|
|
|
+ endstopStrengthUnit: 1,
|
|
|
+ snapPoint: 1.1,
|
|
|
+ text: Mode.Frames,
|
|
|
+ detentPositions: [],
|
|
|
+ snapPointBias: 0,
|
|
|
+
|
|
|
+ zoomTimelinePixelsPerFrame: curConfig.zoomTimelinePixelsPerFrame,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } else if (newMode === Mode.Speed) {
|
|
|
+ setSmartKnobConfig((curConfig) => {
|
|
|
+ return {
|
|
|
+ position: 0,
|
|
|
+ subPositionUnit: 0,
|
|
|
+ positionNonce: (curConfig.positionNonce + 1) % 256,
|
|
|
+ minPosition: playbackState.currentFrame === 0 ? 0 : -6,
|
|
|
+ maxPosition: playbackState.currentFrame === info.totalFrames - 1 ? 0 : 6,
|
|
|
+ positionWidthRadians: (60 * Math.PI) / 180,
|
|
|
+ detentStrengthUnit: 1,
|
|
|
+ endstopStrengthUnit: 1,
|
|
|
+ snapPoint: 0.55,
|
|
|
+ text: Mode.Speed,
|
|
|
+ detentPositions: [],
|
|
|
+ snapPointBias: 0.4,
|
|
|
+
|
|
|
+ zoomTimelinePixelsPerFrame: curConfig.zoomTimelinePixelsPerFrame,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ exhaustiveCheck(newMode)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [detentPositions, info.totalFrames, playbackState],
|
|
|
+ )
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (smartKnobState.config.text === '') {
|
|
|
+ console.debug('No valid state yet')
|
|
|
+ return
|
|
|
}
|
|
|
- }, [derivedState.mode, derivedState.zoomTimelinePixelsPerFrame, detentPositions, state.currentPosition])
|
|
|
-
|
|
|
- // For one-off config pushes, e.g. mode changes
|
|
|
- const pushConfig = (state: State) => {
|
|
|
- let config: PB.SmartKnobConfig
|
|
|
- if (state.mode === Mode.Scroll) {
|
|
|
- const position = Math.trunc((state.currentFrame * state.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION)
|
|
|
- config = PB.SmartKnobConfig.create({
|
|
|
- position,
|
|
|
- minPosition: 0,
|
|
|
- maxPosition: Math.trunc(
|
|
|
- ((info.totalFrames - 1) * state.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
|
|
|
- ),
|
|
|
- positionWidthRadians: (8 * Math.PI) / 180,
|
|
|
- detentStrengthUnit: 2.5,
|
|
|
- endstopStrengthUnit: 1,
|
|
|
- snapPoint: 0.7,
|
|
|
- text: Mode.Scroll,
|
|
|
- detentPositions: findNClosest(Object.keys(detentPositions).map(parseInt), position, 5),
|
|
|
- snapPointBias: 0,
|
|
|
+
|
|
|
+ const currentMode = smartKnobState.config.text as Mode
|
|
|
+ if (currentMode !== smartKnobConfig.text) {
|
|
|
+ console.debug('Mode mismatch, ignoring state', {configMode: smartKnobConfig.text, stateMode: currentMode})
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Update playbackState
|
|
|
+ if (currentMode === Mode.Scroll) {
|
|
|
+ // TODO: round input based on zoom level to avoid noise
|
|
|
+ const rawFrame = Math.trunc(
|
|
|
+ ((smartKnobState.currentPosition + smartKnobState.subPositionUnit) * PIXELS_PER_POSITION) /
|
|
|
+ smartKnobConfig.zoomTimelinePixelsPerFrame,
|
|
|
+ )
|
|
|
+ const frame =
|
|
|
+ detentPositions[smartKnobState.currentPosition]?.[0] ??
|
|
|
+ Math.min(Math.max(rawFrame, 0), info.totalFrames - 1)
|
|
|
+ setPlaybackState({
|
|
|
+ speed: 0,
|
|
|
+ currentFrame: frame,
|
|
|
})
|
|
|
- } else if (state.mode === Mode.Frames) {
|
|
|
- config = PB.SmartKnobConfig.create({
|
|
|
- position: state.currentFrame,
|
|
|
- minPosition: 0,
|
|
|
- maxPosition: info.totalFrames - 1,
|
|
|
- positionWidthRadians: (1.5 * Math.PI) / 180,
|
|
|
- detentStrengthUnit: 1,
|
|
|
- endstopStrengthUnit: 1,
|
|
|
- snapPoint: 1.1,
|
|
|
- text: Mode.Frames,
|
|
|
- detentPositions: [],
|
|
|
- snapPointBias: 0,
|
|
|
+
|
|
|
+ // Update config with N nearest detents
|
|
|
+ setSmartKnobConfig((curConfig) => {
|
|
|
+ let positionInfo: Partial<Config> = {}
|
|
|
+ if (interfaceState.zoomTimelinePixelsPerFrame !== curConfig.zoomTimelinePixelsPerFrame) {
|
|
|
+ const position =
|
|
|
+ (playbackState.currentFrame * interfaceState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION
|
|
|
+ const positionWhole = Math.round(position)
|
|
|
+ const subPositionUnit = position - positionWhole
|
|
|
+ positionInfo = {
|
|
|
+ position,
|
|
|
+ subPositionUnit,
|
|
|
+ positionNonce: (curConfig.positionNonce + 1) % 256,
|
|
|
+ minPosition: 0,
|
|
|
+ maxPosition: Math.trunc(
|
|
|
+ ((info.totalFrames - 1) * interfaceState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
|
|
|
+ ),
|
|
|
+ zoomTimelinePixelsPerFrame: interfaceState.zoomTimelinePixelsPerFrame,
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ ...curConfig,
|
|
|
+ ...positionInfo,
|
|
|
+ detentPositions: nClosestMemo,
|
|
|
+ }
|
|
|
})
|
|
|
- } else if (state.mode === Mode.Speed) {
|
|
|
- config = PB.SmartKnobConfig.create({
|
|
|
- position: state.playbackSpeed === 0 ? 0 : INT32_MIN,
|
|
|
- minPosition: state.currentFrame === 0 ? 0 : -6,
|
|
|
- maxPosition: state.currentFrame === info.totalFrames - 1 ? 0 : 6,
|
|
|
- positionWidthRadians: (60 * Math.PI) / 180,
|
|
|
- detentStrengthUnit: 1,
|
|
|
- endstopStrengthUnit: 1,
|
|
|
- snapPoint: 0.55,
|
|
|
- text: Mode.Speed,
|
|
|
- detentPositions: [],
|
|
|
- snapPointBias: 0.4,
|
|
|
+ } else if (currentMode === Mode.Frames) {
|
|
|
+ setPlaybackState({
|
|
|
+ speed: 0,
|
|
|
+ currentFrame: smartKnobState.currentPosition,
|
|
|
+ })
|
|
|
+ // No config updates needed
|
|
|
+ } else if (currentMode === Mode.Speed) {
|
|
|
+ const normalizedWholeValue = smartKnobState.currentPosition
|
|
|
+ const normalizedFractional =
|
|
|
+ Math.sign(smartKnobState.subPositionUnit) *
|
|
|
+ lerp(smartKnobState.subPositionUnit * Math.sign(smartKnobState.subPositionUnit), 0.1, 0.9, 0, 1)
|
|
|
+ const normalized = normalizedWholeValue + normalizedFractional
|
|
|
+ const speed = Math.sign(normalized) * Math.pow(2, Math.abs(normalized) - 1)
|
|
|
+ const roundedSpeed = Math.trunc(speed * 10) / 10
|
|
|
+ setPlaybackState((cur) => {
|
|
|
+ return {
|
|
|
+ speed: roundedSpeed,
|
|
|
+ currentFrame: cur.currentFrame,
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // Update config with bounds depending on current frame
|
|
|
+ setSmartKnobConfig((curConfig) => {
|
|
|
+ return {
|
|
|
+ ...curConfig,
|
|
|
+ minPosition: playbackState.currentFrame === 0 ? 0 : -6,
|
|
|
+ maxPosition: playbackState.currentFrame === info.totalFrames - 1 ? 0 : 6,
|
|
|
+ }
|
|
|
})
|
|
|
} else {
|
|
|
- throw exhaustiveCheck(state.mode)
|
|
|
+ exhaustiveCheck(currentMode)
|
|
|
}
|
|
|
- socket.emit('set_config', config)
|
|
|
- }
|
|
|
-
|
|
|
- const setCurrentFrame = (fn: (oldFrame: number) => number) => {
|
|
|
- setDerivedState((cur) => {
|
|
|
- const newState = {...cur}
|
|
|
- if (cur.mode === Mode.Speed) {
|
|
|
- newState.currentFrame = fn(cur.currentFrame)
|
|
|
+ }, [
|
|
|
+ detentPositions,
|
|
|
+ nClosestMemo,
|
|
|
+ info.totalFrames,
|
|
|
+ smartKnobState.config.text,
|
|
|
+ smartKnobState.currentPosition,
|
|
|
+ smartKnobState.subPositionUnit,
|
|
|
+ smartKnobConfig.text,
|
|
|
+ playbackState.currentFrame,
|
|
|
+ playbackState.speed,
|
|
|
+ interfaceState.zoomTimelinePixelsPerFrame,
|
|
|
+ ])
|
|
|
+
|
|
|
+ const refreshInterval = 20
|
|
|
+ const updateFrame = useCallback(() => {
|
|
|
+ const fps = info.frameRate * playbackState.speed
|
|
|
+ setPlaybackState((cur) => {
|
|
|
+ const newFrame = cur.currentFrame + (fps * refreshInterval) / 1000
|
|
|
+ const clampedNewFrame = Math.min(Math.max(newFrame, 0), info.totalFrames - 1)
|
|
|
+ return {
|
|
|
+ speed: cur.speed,
|
|
|
+ currentFrame: clampedNewFrame,
|
|
|
}
|
|
|
- return newState
|
|
|
})
|
|
|
- }
|
|
|
+ }, [info.frameRate, playbackState.speed])
|
|
|
+
|
|
|
+ // Store the latest callback in a ref so the long-lived interval closure can invoke the latest version.
|
|
|
+ // See https://overreacted.io/making-setinterval-declarative-with-react-hooks/ for more
|
|
|
+ const savedCallback = useRef<() => void | null>()
|
|
|
+ useEffect(() => {
|
|
|
+ savedCallback.current = updateFrame
|
|
|
+ }, [updateFrame])
|
|
|
+
|
|
|
+ const isPlaying = useMemo(() => {
|
|
|
+ return playbackState.speed !== 0
|
|
|
+ }, [playbackState.speed])
|
|
|
|
|
|
- // Timer for speed-based playback
|
|
|
useEffect(() => {
|
|
|
- const refreshInterval = 20
|
|
|
- const fps = info.frameRate * derivedState.playbackSpeed
|
|
|
- if (derivedState.mode === Mode.Speed && fps !== 0) {
|
|
|
+ if (smartKnobState.config.text === Mode.Speed && isPlaying) {
|
|
|
const timer = setInterval(() => {
|
|
|
- setCurrentFrame((oldFrame) => {
|
|
|
- const newFrame = oldFrame + (fps * refreshInterval) / 1000
|
|
|
-
|
|
|
- const oldFrameTrunc = Math.trunc(oldFrame)
|
|
|
- const newFrameTrunc = Math.trunc(newFrame)
|
|
|
-
|
|
|
- if (newFrame < 0 || newFrame >= info.totalFrames) {
|
|
|
- const clampedNewFrame = Math.min(Math.max(newFrame, 0), info.totalFrames - 1)
|
|
|
- if (oldFrame !== clampedNewFrame) {
|
|
|
- // If we've hit a boundary, push a config to set the bounds
|
|
|
- pushConfig({
|
|
|
- mode: Mode.Speed,
|
|
|
- playbackSpeed: 0,
|
|
|
- currentFrame: Math.trunc(clampedNewFrame),
|
|
|
- zoomTimelinePixelsPerFrame: derivedState.zoomTimelinePixelsPerFrame,
|
|
|
- })
|
|
|
- }
|
|
|
- return clampedNewFrame
|
|
|
- } else {
|
|
|
- if (
|
|
|
- (oldFrameTrunc === 0 && newFrameTrunc > 0) ||
|
|
|
- (oldFrameTrunc === info.totalFrames - 1 && newFrameTrunc < info.totalFrames - 1)
|
|
|
- ) {
|
|
|
- // If we've left a boundary condition, push a config to reset the bounds
|
|
|
- pushConfig({
|
|
|
- mode: derivedState.mode,
|
|
|
- playbackSpeed: 0,
|
|
|
- currentFrame: newFrameTrunc,
|
|
|
- zoomTimelinePixelsPerFrame: derivedState.zoomTimelinePixelsPerFrame,
|
|
|
- })
|
|
|
- }
|
|
|
- return newFrame
|
|
|
- }
|
|
|
- })
|
|
|
+ if (savedCallback.current) {
|
|
|
+ savedCallback.current()
|
|
|
+ }
|
|
|
}, refreshInterval)
|
|
|
return () => clearInterval(timer)
|
|
|
}
|
|
|
- }, [derivedState.mode, derivedState.playbackSpeed, info.totalFrames, info.frameRate])
|
|
|
+ }, [smartKnobState.config.text, isPlaying])
|
|
|
|
|
|
// Socket.io subscription
|
|
|
useEffect(() => {
|
|
|
socket.on('connect', () => {
|
|
|
setIsConnected(true)
|
|
|
- pushConfig(derivedState)
|
|
|
})
|
|
|
|
|
|
socket.on('disconnect', () => {
|
|
|
@@ -237,7 +329,7 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
const stateObj = PB.SmartKnobState.toObject(state, {
|
|
|
defaults: true,
|
|
|
}) as NoUndefinedField<PB.ISmartKnobState>
|
|
|
- setState(stateObj)
|
|
|
+ setSmartKnobState(stateObj)
|
|
|
})
|
|
|
return () => {
|
|
|
socket.off('connect')
|
|
|
@@ -260,16 +352,13 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
)}
|
|
|
<ToggleButtonGroup
|
|
|
color="primary"
|
|
|
- value={derivedState.mode}
|
|
|
+ value={smartKnobConfig.text}
|
|
|
exclusive
|
|
|
onChange={(e, value: Mode | null) => {
|
|
|
if (value === null) {
|
|
|
return
|
|
|
}
|
|
|
- pushConfig({
|
|
|
- ...derivedState,
|
|
|
- mode: value,
|
|
|
- })
|
|
|
+ changeMode(value)
|
|
|
}}
|
|
|
aria-label="Mode"
|
|
|
>
|
|
|
@@ -280,18 +369,18 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
))}
|
|
|
</ToggleButtonGroup>
|
|
|
<Typography>
|
|
|
- Frame {Math.trunc(derivedState.currentFrame)} / {info.totalFrames - 1}
|
|
|
+ Frame {Math.trunc(playbackState.currentFrame)} / {info.totalFrames - 1}
|
|
|
<br />
|
|
|
- Speed {Math.trunc(derivedState.playbackSpeed * 10) / 10}
|
|
|
+ Speed {playbackState.speed}
|
|
|
</Typography>
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
<Timeline
|
|
|
info={info}
|
|
|
- currentFrame={derivedState.currentFrame}
|
|
|
- zoomTimelinePixelsPerFrame={derivedState.zoomTimelinePixelsPerFrame}
|
|
|
+ currentFrame={playbackState.currentFrame}
|
|
|
+ zoomTimelinePixelsPerFrame={interfaceState.zoomTimelinePixelsPerFrame}
|
|
|
adjustZoom={(factor) => {
|
|
|
- setDerivedState((cur) => {
|
|
|
+ setInterfaceState((cur) => {
|
|
|
const newZoom = Math.min(
|
|
|
Math.max(cur.zoomTimelinePixelsPerFrame * factor, MIN_ZOOM),
|
|
|
MAX_ZOOM,
|
|
|
@@ -306,7 +395,7 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
/>
|
|
|
<Card>
|
|
|
<CardContent>
|
|
|
- <div>{JSON.stringify(detentPositions)}</div>
|
|
|
+ <div>{JSON.stringify(smartKnobConfig)}</div>
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
</Container>
|