App.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
  2. import Typography from '@mui/material/Typography'
  3. import Container from '@mui/material/Container'
  4. import ToggleButton from '@mui/material/ToggleButton'
  5. import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
  6. import {PB} from 'smartknobjs-proto'
  7. import {VideoInfo} from './types'
  8. import {Button, CardActions, Paper} from '@mui/material'
  9. import {exhaustiveCheck, findNClosest, lerp, NoUndefinedField} from './util'
  10. import {groupBy, parseInt} from 'lodash'
  11. import _ from 'lodash'
  12. import {SmartKnobWebSerial} from 'smartknobjs-webserial'
  13. const MIN_ZOOM = 0.01
  14. const MAX_ZOOM = 60
  15. const PIXELS_PER_POSITION = 5
  16. enum Mode {
  17. Scroll = 'SKDEMO_Scroll',
  18. Frames = 'SKDEMO_Frames',
  19. Speed = 'SKDEMO_Speed',
  20. }
  21. type PlaybackState = {
  22. speed: number
  23. currentFrame: number
  24. }
  25. type InterfaceState = {
  26. zoomTimelinePixelsPerFrame: number
  27. }
  28. type Config = NoUndefinedField<PB.ISmartKnobConfig> & {
  29. zoomTimelinePixelsPerFrame: number
  30. }
  31. export type AppProps = {
  32. info: VideoInfo
  33. }
  34. export const App: React.FC<AppProps> = ({info}) => {
  35. const [smartKnob, setSmartKnob] = useState<SmartKnobWebSerial | null>(null)
  36. const [smartKnobState, setSmartKnobState] = useState<NoUndefinedField<PB.ISmartKnobState>>(
  37. PB.SmartKnobState.toObject(PB.SmartKnobState.create({config: PB.SmartKnobConfig.create()}), {
  38. defaults: true,
  39. }) as NoUndefinedField<PB.ISmartKnobState>,
  40. )
  41. const [smartKnobConfig, setSmartKnobConfig] = useState<Config>({
  42. position: 0,
  43. subPositionUnit: 0,
  44. positionNonce: Math.floor(Math.random() * 255),
  45. minPosition: 0,
  46. maxPosition: 0,
  47. positionWidthRadians: (15 * Math.PI) / 180,
  48. detentStrengthUnit: 0,
  49. endstopStrengthUnit: 1,
  50. snapPoint: 0.7,
  51. text: Mode.Scroll,
  52. detentPositions: [],
  53. snapPointBias: 0,
  54. ledHue: 0,
  55. zoomTimelinePixelsPerFrame: 0.1,
  56. })
  57. useEffect(() => {
  58. console.log('send config', smartKnobConfig)
  59. smartKnob?.sendConfig(PB.SmartKnobConfig.create(smartKnobConfig))
  60. }, [
  61. smartKnob,
  62. smartKnobConfig.position,
  63. smartKnobConfig.subPositionUnit,
  64. smartKnobConfig.positionNonce,
  65. smartKnobConfig.minPosition,
  66. smartKnobConfig.maxPosition,
  67. smartKnobConfig.positionWidthRadians,
  68. smartKnobConfig.detentStrengthUnit,
  69. smartKnobConfig.endstopStrengthUnit,
  70. smartKnobConfig.snapPoint,
  71. smartKnobConfig.text,
  72. smartKnobConfig.detentPositions,
  73. smartKnobConfig.snapPointBias,
  74. smartKnobConfig.ledHue,
  75. ])
  76. const [playbackState, setPlaybackState] = useState<PlaybackState>({
  77. speed: 0,
  78. currentFrame: 0,
  79. })
  80. const [interfaceState, setInterfaceState] = useState<InterfaceState>({
  81. zoomTimelinePixelsPerFrame: 0.3,
  82. })
  83. const totalPositions = Math.ceil(
  84. (info.totalFrames * smartKnobConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
  85. )
  86. const detentPositions = useMemo(() => {
  87. // Always include the first and last positions at detents
  88. const positionsToFrames = groupBy([0, ...info.boundaryFrames, info.totalFrames - 1], (frame) =>
  89. Math.round((frame * smartKnobConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION),
  90. )
  91. console.log(JSON.stringify(positionsToFrames))
  92. return positionsToFrames
  93. }, [info.boundaryFrames, info.totalFrames, totalPositions, smartKnobConfig.zoomTimelinePixelsPerFrame])
  94. const scrollPositionWholeMemo = useMemo(() => {
  95. const position = (playbackState.currentFrame * smartKnobConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION
  96. return Math.round(position)
  97. }, [playbackState.currentFrame, smartKnobConfig.zoomTimelinePixelsPerFrame])
  98. const [nClosest, setNClosest] = useState<Array<number>>([])
  99. useEffect(() => {
  100. const calculated = findNClosest(Object.keys(detentPositions).map(parseInt), scrollPositionWholeMemo, 5).sort(
  101. (a, b) => a - b,
  102. )
  103. setNClosest((cur) => {
  104. if (_.isEqual(cur, calculated)) {
  105. return cur
  106. }
  107. return calculated
  108. })
  109. }, [scrollPositionWholeMemo])
  110. const changeMode = useCallback(
  111. (newMode: Mode) => {
  112. if (newMode === Mode.Scroll) {
  113. setSmartKnobConfig((curConfig) => {
  114. const position =
  115. (playbackState.currentFrame * curConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION
  116. const positionWhole = Math.round(position)
  117. const subPositionUnit = position - positionWhole
  118. return {
  119. position: positionWhole,
  120. subPositionUnit,
  121. positionNonce: (curConfig.positionNonce + 1) % 256,
  122. minPosition: 0,
  123. maxPosition: Math.round(
  124. ((info.totalFrames - 1) * curConfig.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
  125. ),
  126. positionWidthRadians: (8 * Math.PI) / 180,
  127. detentStrengthUnit: 3,
  128. endstopStrengthUnit: 1,
  129. snapPoint: 0.7,
  130. text: Mode.Scroll,
  131. detentPositions: findNClosest(Object.keys(detentPositions).map(parseInt), position, 5),
  132. snapPointBias: 0,
  133. ledHue: 0,
  134. zoomTimelinePixelsPerFrame: curConfig.zoomTimelinePixelsPerFrame,
  135. }
  136. })
  137. } else if (newMode === Mode.Frames) {
  138. setSmartKnobConfig((curConfig) => {
  139. return {
  140. position: Math.floor(playbackState.currentFrame),
  141. subPositionUnit: 0,
  142. positionNonce: (curConfig.positionNonce + 1) % 256,
  143. minPosition: 0,
  144. maxPosition: info.totalFrames - 1,
  145. positionWidthRadians: (1.8 * Math.PI) / 180,
  146. detentStrengthUnit: 1,
  147. endstopStrengthUnit: 1,
  148. snapPoint: 1.1,
  149. text: Mode.Frames,
  150. detentPositions: [],
  151. snapPointBias: 0,
  152. ledHue: (120 * 255) / 360,
  153. zoomTimelinePixelsPerFrame: curConfig.zoomTimelinePixelsPerFrame,
  154. }
  155. })
  156. } else if (newMode === Mode.Speed) {
  157. setSmartKnobConfig((curConfig) => {
  158. return {
  159. position: 0,
  160. subPositionUnit: 0,
  161. positionNonce: (curConfig.positionNonce + 1) % 256,
  162. minPosition: playbackState.currentFrame === 0 ? 0 : -6,
  163. maxPosition: playbackState.currentFrame === info.totalFrames - 1 ? 0 : 6,
  164. positionWidthRadians: (30 * Math.PI) / 180,
  165. detentStrengthUnit: 1,
  166. endstopStrengthUnit: 1,
  167. snapPoint: 0.5,
  168. text: Mode.Speed,
  169. detentPositions: [],
  170. snapPointBias: 0.4,
  171. ledHue: (240 * 255) / 360,
  172. zoomTimelinePixelsPerFrame: curConfig.zoomTimelinePixelsPerFrame,
  173. }
  174. })
  175. } else {
  176. exhaustiveCheck(newMode)
  177. }
  178. },
  179. [detentPositions, info.totalFrames, playbackState],
  180. )
  181. const nextMode = useCallback(() => {
  182. const curMode = smartKnobConfig.text as unknown as Mode
  183. console.log('nextMode', curMode)
  184. if (curMode === Mode.Scroll) {
  185. changeMode(Mode.Frames)
  186. } else if (curMode === Mode.Frames) {
  187. changeMode(Mode.Speed)
  188. } else if (curMode === Mode.Speed) {
  189. changeMode(Mode.Scroll)
  190. } else {
  191. exhaustiveCheck(curMode)
  192. }
  193. }, [smartKnobConfig.text, changeMode])
  194. // Initialize to Scroll mode
  195. useEffect(() => {
  196. changeMode(Mode.Scroll)
  197. }, [])
  198. useEffect(() => {
  199. if (smartKnobState.config.text === '') {
  200. console.debug('No valid state yet')
  201. return
  202. }
  203. const currentMode = smartKnobState.config.text as Mode
  204. if (currentMode !== smartKnobConfig.text) {
  205. console.debug('Mode mismatch, ignoring state', {configMode: smartKnobConfig.text, stateMode: currentMode})
  206. return
  207. }
  208. // Update playbackState
  209. if (currentMode === Mode.Scroll) {
  210. // TODO: round input based on zoom level to avoid noise
  211. const rawFrame = Math.trunc(
  212. ((smartKnobState.currentPosition + smartKnobState.subPositionUnit) * PIXELS_PER_POSITION) /
  213. smartKnobConfig.zoomTimelinePixelsPerFrame,
  214. )
  215. const frame =
  216. detentPositions[smartKnobState.currentPosition]?.[0] ??
  217. Math.min(Math.max(rawFrame, 0), info.totalFrames - 1)
  218. setPlaybackState({
  219. speed: 0,
  220. currentFrame: frame,
  221. })
  222. // Update config with N nearest detents
  223. setSmartKnobConfig((curConfig) => {
  224. let positionInfo: Partial<Config> = {}
  225. if (interfaceState.zoomTimelinePixelsPerFrame !== curConfig.zoomTimelinePixelsPerFrame) {
  226. const position =
  227. (playbackState.currentFrame * interfaceState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION
  228. const positionWhole = Math.round(position)
  229. const subPositionUnit = position - positionWhole
  230. positionInfo = {
  231. position,
  232. subPositionUnit,
  233. positionNonce: (curConfig.positionNonce + 1) % 256,
  234. minPosition: 0,
  235. maxPosition: Math.round(
  236. ((info.totalFrames - 1) * interfaceState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
  237. ),
  238. zoomTimelinePixelsPerFrame: interfaceState.zoomTimelinePixelsPerFrame,
  239. }
  240. }
  241. return {
  242. ...curConfig,
  243. ...positionInfo,
  244. detentPositions: nClosest,
  245. }
  246. })
  247. } else if (currentMode === Mode.Frames) {
  248. setPlaybackState({
  249. speed: 0,
  250. currentFrame: smartKnobState.currentPosition,
  251. })
  252. // No config updates needed
  253. } else if (currentMode === Mode.Speed) {
  254. const normalizedWholeValue = smartKnobState.currentPosition
  255. const normalizedFractional =
  256. Math.sign(smartKnobState.subPositionUnit) *
  257. lerp(smartKnobState.subPositionUnit * Math.sign(smartKnobState.subPositionUnit), 0.1, 0.9, 0, 1)
  258. const normalized = normalizedWholeValue + normalizedFractional
  259. const speed = Math.sign(normalized) * Math.pow(2, Math.abs(normalized) - 1)
  260. const roundedSpeed = Math.trunc(speed * 10) / 10
  261. setPlaybackState((cur) => {
  262. return {
  263. speed: roundedSpeed,
  264. currentFrame: cur.currentFrame,
  265. }
  266. })
  267. // Update config with bounds depending on current frame
  268. setSmartKnobConfig((curConfig) => {
  269. return {
  270. ...curConfig,
  271. minPosition: playbackState.currentFrame === 0 ? 0 : -6,
  272. maxPosition: playbackState.currentFrame === info.totalFrames - 1 ? 0 : 6,
  273. }
  274. })
  275. } else {
  276. exhaustiveCheck(currentMode)
  277. }
  278. }, [
  279. detentPositions,
  280. nClosest,
  281. info.totalFrames,
  282. smartKnobState.config.text,
  283. smartKnobState.currentPosition,
  284. smartKnobState.subPositionUnit,
  285. smartKnobConfig.text,
  286. playbackState.currentFrame,
  287. playbackState.speed,
  288. interfaceState.zoomTimelinePixelsPerFrame,
  289. ])
  290. // Change mode when pressed
  291. const receivedPressNonceRef = useRef<boolean>(false)
  292. const previousPressNonceRef = useRef<number>(0)
  293. useEffect(() => {
  294. if (previousPressNonceRef.current !== smartKnobState.pressNonce) {
  295. if (!receivedPressNonceRef.current) {
  296. // Ignore first nonce change
  297. receivedPressNonceRef.current = true
  298. } else {
  299. nextMode()
  300. }
  301. }
  302. previousPressNonceRef.current = smartKnobState.pressNonce
  303. }, [smartKnobState.pressNonce, nextMode])
  304. const refreshInterval = 20
  305. const updateFrame = useCallback(() => {
  306. const fps = info.frameRate * playbackState.speed
  307. setPlaybackState((cur) => {
  308. const newFrame = cur.currentFrame + (fps * refreshInterval) / 1000
  309. const clampedNewFrame = Math.min(Math.max(newFrame, 0), info.totalFrames - 1)
  310. return {
  311. speed: cur.speed,
  312. currentFrame: clampedNewFrame,
  313. }
  314. })
  315. }, [info.frameRate, playbackState.speed])
  316. // Store the latest callback in a ref so the long-lived interval closure can invoke the latest version.
  317. // See https://overreacted.io/making-setinterval-declarative-with-react-hooks/ for more
  318. const savedCallback = useRef<() => void | null>()
  319. useEffect(() => {
  320. savedCallback.current = updateFrame
  321. }, [updateFrame])
  322. const isPlaying = useMemo(() => {
  323. return playbackState.speed !== 0
  324. }, [playbackState.speed])
  325. useEffect(() => {
  326. if (smartKnobState.config.text === Mode.Speed && isPlaying) {
  327. const timer = setInterval(() => {
  328. if (savedCallback.current) {
  329. savedCallback.current()
  330. }
  331. }, refreshInterval)
  332. return () => clearInterval(timer)
  333. }
  334. }, [smartKnobState.config.text, isPlaying])
  335. const connectToSerial = async () => {
  336. try {
  337. if (navigator.serial) {
  338. previousPressNonceRef.current = 0
  339. receivedPressNonceRef.current = false
  340. const serialPort = await navigator.serial.requestPort({
  341. filters: SmartKnobWebSerial.USB_DEVICE_FILTERS,
  342. })
  343. serialPort.addEventListener('disconnect', () => {
  344. setSmartKnob(null)
  345. })
  346. const smartKnob = new SmartKnobWebSerial(serialPort, (message) => {
  347. if (message.payload === 'smartknobState' && message.smartknobState !== null) {
  348. const state = PB.SmartKnobState.create(message.smartknobState)
  349. const stateObj = PB.SmartKnobState.toObject(state, {
  350. defaults: true,
  351. }) as NoUndefinedField<PB.ISmartKnobState>
  352. setSmartKnobState(stateObj)
  353. } else if (message.payload === 'log' && message.log !== null) {
  354. console.log('LOG from smartknob', message.log?.msg)
  355. }
  356. })
  357. setSmartKnob(smartKnob)
  358. await smartKnob.openAndLoop()
  359. } else {
  360. console.error('Web Serial API is not supported in this browser.')
  361. setSmartKnob(null)
  362. }
  363. } catch (error) {
  364. console.error('Error with serial port:', error)
  365. setSmartKnob(null)
  366. }
  367. }
  368. return (
  369. <>
  370. <Container component="main" maxWidth="lg">
  371. <Paper variant="outlined" sx={{my: {xs: 3, md: 6}, p: {xs: 2, md: 3}}}>
  372. <Typography component="h1" variant="h5">
  373. Video Playback Control Demo
  374. </Typography>
  375. {smartKnob !== null ? (
  376. <>
  377. <ToggleButtonGroup
  378. color="primary"
  379. value={smartKnobConfig.text}
  380. exclusive
  381. onChange={(e, value: Mode | null) => {
  382. if (value === null) {
  383. return
  384. }
  385. changeMode(value)
  386. }}
  387. aria-label="Mode"
  388. >
  389. {Object.entries(Mode).map((mode_entry) => (
  390. <ToggleButton value={mode_entry[1]} key={mode_entry[1]}>
  391. {mode_entry[0]}
  392. </ToggleButton>
  393. ))}
  394. </ToggleButtonGroup>
  395. <Typography>
  396. Frame {Math.trunc(playbackState.currentFrame)} / {info.totalFrames - 1}
  397. <br />
  398. Speed {playbackState.speed}
  399. </Typography>
  400. <Timeline
  401. info={info}
  402. currentFrame={playbackState.currentFrame}
  403. zoomTimelinePixelsPerFrame={interfaceState.zoomTimelinePixelsPerFrame}
  404. adjustZoom={(factor) => {
  405. setInterfaceState((cur) => {
  406. const newZoom = Math.min(
  407. Math.max(cur.zoomTimelinePixelsPerFrame * factor, MIN_ZOOM),
  408. MAX_ZOOM,
  409. )
  410. console.log(factor, newZoom)
  411. return {
  412. ...cur,
  413. zoomTimelinePixelsPerFrame: newZoom,
  414. }
  415. })
  416. }}
  417. />
  418. </>
  419. ) : navigator.serial ? (
  420. <CardActions>
  421. <Button onClick={connectToSerial} variant="contained">
  422. Connect via Web Serial
  423. </Button>
  424. </CardActions>
  425. ) : (
  426. <Typography>
  427. Sorry, Web Serial API isn't available in your browser. Try the latest version of Chrome.
  428. </Typography>
  429. )}
  430. <pre>{JSON.stringify(smartKnobConfig, undefined, 2)}</pre>
  431. </Paper>
  432. </Container>
  433. </>
  434. )
  435. }
  436. export type TimelineProps = {
  437. info: VideoInfo
  438. currentFrame: number
  439. zoomTimelinePixelsPerFrame: number
  440. adjustZoom: (factor: number) => void
  441. }
  442. export const Timeline: React.FC<TimelineProps> = ({info, currentFrame, zoomTimelinePixelsPerFrame, adjustZoom}) => {
  443. const gradients = [
  444. 'linear-gradient( 135deg, #FDEB71 10%, #F8D800 100%)',
  445. 'linear-gradient( 135deg, #ABDCFF 10%, #0396FF 100%)',
  446. 'linear-gradient( 135deg, #FEB692 10%, #EA5455 100%)',
  447. 'linear-gradient( 135deg, #CE9FFC 10%, #7367F0 100%)',
  448. 'linear-gradient( 135deg, #90F7EC 10%, #32CCBC 100%)',
  449. ]
  450. const timelineRef = useRef<HTMLDivElement>(null)
  451. const cursorRef = useRef<HTMLDivElement>(null)
  452. useEffect(() => {
  453. const handleWheel = (event: HTMLElementEventMap['wheel']) => {
  454. const delta = event.deltaY
  455. if (delta) {
  456. event.preventDefault()
  457. adjustZoom(1 - delta / 500)
  458. }
  459. }
  460. timelineRef.current?.addEventListener('wheel', handleWheel)
  461. return () => {
  462. timelineRef.current?.removeEventListener('wheel', handleWheel)
  463. }
  464. }, [])
  465. useEffect(() => {
  466. cursorRef.current?.scrollIntoView()
  467. }, [currentFrame, zoomTimelinePixelsPerFrame])
  468. return (
  469. <div
  470. className="timeline-container"
  471. ref={timelineRef}
  472. style={{
  473. width: '100%',
  474. margin: '10px auto',
  475. overflowX: 'scroll',
  476. }}
  477. >
  478. <div
  479. className="timeline"
  480. style={{
  481. position: 'relative',
  482. display: 'inline-block',
  483. height: '80px',
  484. width: `${zoomTimelinePixelsPerFrame * info.totalFrames}px`,
  485. backgroundColor: '#dde',
  486. }}
  487. >
  488. {[...info.boundaryFrames, info.totalFrames].map((f, i, a) => {
  489. const lengthFrames = f - (a[i - 1] ?? 0)
  490. const widthPixels = zoomTimelinePixelsPerFrame * lengthFrames
  491. return (
  492. <div
  493. key={`clip-${f}`}
  494. className="video-clip"
  495. style={{
  496. position: 'relative',
  497. display: 'inline-block',
  498. top: '10px',
  499. height: '60px',
  500. width: `${widthPixels}px`,
  501. backgroundImage: gradients[i % gradients.length],
  502. }}
  503. ></div>
  504. )
  505. })}
  506. <div
  507. className="playback-cursor"
  508. ref={cursorRef}
  509. style={{
  510. position: 'absolute',
  511. display: 'inline-block',
  512. left: `${zoomTimelinePixelsPerFrame * Math.trunc(currentFrame)}px`,
  513. width: `${Math.max(zoomTimelinePixelsPerFrame, 1)}px`,
  514. height: '100%',
  515. backgroundColor: 'rgba(255, 0, 0, 0.4)',
  516. borderLeft: '1px solid red',
  517. }}
  518. ></div>
  519. </div>
  520. </div>
  521. )
  522. }