@arcanewizards/timecode-toolbox 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +55 -0
  2. package/CHANGELOG.md +24 -0
  3. package/eslint.config.mjs +49 -0
  4. package/package.json +74 -0
  5. package/src/app.tsx +147 -0
  6. package/src/components/backend/index.ts +6 -0
  7. package/src/components/backend/toolbox-root.ts +119 -0
  8. package/src/components/frontend/constants.ts +81 -0
  9. package/src/components/frontend/entrypoint.ts +12 -0
  10. package/src/components/frontend/frontend.css +108 -0
  11. package/src/components/frontend/index.tsx +46 -0
  12. package/src/components/frontend/toolbox/content.tsx +45 -0
  13. package/src/components/frontend/toolbox/context.tsx +63 -0
  14. package/src/components/frontend/toolbox/core/size-aware-div.tsx +51 -0
  15. package/src/components/frontend/toolbox/core/timecode-display.tsx +592 -0
  16. package/src/components/frontend/toolbox/generators.tsx +318 -0
  17. package/src/components/frontend/toolbox/inputs.tsx +484 -0
  18. package/src/components/frontend/toolbox/outputs.tsx +581 -0
  19. package/src/components/frontend/toolbox/preferences.ts +25 -0
  20. package/src/components/frontend/toolbox/root.tsx +335 -0
  21. package/src/components/frontend/toolbox/settings.tsx +54 -0
  22. package/src/components/frontend/toolbox/types.ts +28 -0
  23. package/src/components/frontend/toolbox/util.tsx +61 -0
  24. package/src/components/proto.ts +420 -0
  25. package/src/config.ts +7 -0
  26. package/src/generators/clock.tsx +206 -0
  27. package/src/generators/index.tsx +15 -0
  28. package/src/index.ts +38 -0
  29. package/src/inputs/artnet.tsx +305 -0
  30. package/src/inputs/index.tsx +13 -0
  31. package/src/inputs/tcnet.tsx +272 -0
  32. package/src/outputs/artnet.tsx +170 -0
  33. package/src/outputs/index.tsx +11 -0
  34. package/src/start.ts +47 -0
  35. package/src/tree.ts +133 -0
  36. package/src/types.ts +12 -0
  37. package/src/urls.ts +49 -0
  38. package/src/util.ts +82 -0
  39. package/tailwind.config.cjs +7 -0
  40. package/tsconfig.json +10 -0
  41. package/tsup.config.ts +10 -0
@@ -0,0 +1,592 @@
1
+ import {
2
+ FC,
3
+ ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useState,
9
+ } from 'react';
10
+ import {
11
+ isTimecodeGroup,
12
+ isTimecodeInstance,
13
+ TimecodeGroup,
14
+ TimecodeInstance,
15
+ TimecodeState,
16
+ TimecodeTotalTime,
17
+ TimecodeInstanceId,
18
+ isOutputInstanceId,
19
+ isInputInstanceId,
20
+ isGeneratorInstanceId,
21
+ } from '../../../proto';
22
+ import { displayMillis } from '../util';
23
+ import { StageContext } from '@arcanejs/toolkit-frontend';
24
+ import {
25
+ cnd,
26
+ cssSigilColorUsageVariables,
27
+ SigilColor,
28
+ sigilColorUsage,
29
+ } from '@arcanewizards/sigil/frontend/styling';
30
+ import { cn } from '@arcanejs/toolkit-frontend/util';
31
+ import {
32
+ ControlButton,
33
+ ControlButtonGroup,
34
+ } from '@arcanewizards/sigil/frontend/controls';
35
+ import { TooltipWrapper } from '@arcanewizards/sigil/frontend/tooltip';
36
+ import { STRINGS } from '../../constants';
37
+ import { AssignToOutputCallback } from '../types';
38
+ import { Icon } from '@arcanejs/toolkit-frontend/components/core';
39
+ import { SizeAwareDiv } from './size-aware-div';
40
+ import {
41
+ ApplicationStateContext,
42
+ ConfigContext,
43
+ useApplicationHandlers,
44
+ } from '../context';
45
+ import { getTreeValue } from '../../../../tree';
46
+ import { useBrowserContext } from '@arcanewizards/sigil/frontend';
47
+ import { WINDOW_MODE_TIMECODE, withUrlFragment } from '../../../../urls';
48
+ import {
49
+ augmentUpstreamTimecodeWithOutputMetadata,
50
+ getTimecodeInstance,
51
+ } from '../../../../util';
52
+
53
+ type ActiveTimecodeTextProps = {
54
+ effectiveStartTimeMillis: number;
55
+ speed: number;
56
+ };
57
+
58
+ const ActiveTimecodeText: FC<ActiveTimecodeTextProps> = ({
59
+ effectiveStartTimeMillis,
60
+ speed,
61
+ }) => {
62
+ const [millis, setMillis] = useState(0);
63
+
64
+ const { timeDifferenceMs } = useContext(StageContext);
65
+
66
+ useEffect(() => {
67
+ let animationFrame: number | null = null;
68
+
69
+ const updateMillis = () => {
70
+ const newMillis =
71
+ (Date.now() - (timeDifferenceMs ?? 0) - effectiveStartTimeMillis) *
72
+ speed;
73
+ setMillis(newMillis);
74
+ animationFrame = requestAnimationFrame(updateMillis);
75
+ };
76
+ updateMillis();
77
+ return () => {
78
+ if (animationFrame !== null) {
79
+ cancelAnimationFrame(animationFrame);
80
+ }
81
+ };
82
+ }, [effectiveStartTimeMillis, speed, timeDifferenceMs]);
83
+
84
+ return displayMillis(millis);
85
+ };
86
+
87
+ type TimelineProps = {
88
+ state: TimecodeState;
89
+ totalTime: TimecodeTotalTime;
90
+ };
91
+
92
+ const Timeline: FC<TimelineProps> = ({ state, totalTime }) => {
93
+ const [millis, setMillis] = useState(0);
94
+
95
+ const { timeDifferenceMs } = useContext(StageContext);
96
+
97
+ useEffect(() => {
98
+ if (state.state === 'none') {
99
+ setMillis(0);
100
+ return;
101
+ }
102
+
103
+ if (state.state === 'stopped') {
104
+ setMillis(state.positionMillis);
105
+ return;
106
+ }
107
+
108
+ let animationFrame: number | null = null;
109
+
110
+ const updateMillis = () => {
111
+ const newMillis =
112
+ (Date.now() -
113
+ (timeDifferenceMs ?? 0) -
114
+ state.effectiveStartTimeMillis) *
115
+ state.speed;
116
+ setMillis(newMillis);
117
+ animationFrame = requestAnimationFrame(updateMillis);
118
+ };
119
+ updateMillis();
120
+ return () => {
121
+ if (animationFrame !== null) {
122
+ cancelAnimationFrame(animationFrame);
123
+ }
124
+ };
125
+ }, [state, timeDifferenceMs]);
126
+
127
+ return (
128
+ <div className="w-full border border-timecode-usage-foreground p-px">
129
+ <div className="relative h-1 w-full overflow-hidden">
130
+ <div
131
+ className="absolute inset-y-0 left-0 bg-timecode-usage-foreground"
132
+ style={{
133
+ width: `${Math.min((millis / totalTime.timeMillis) * 100, 100)}%`,
134
+ }}
135
+ />
136
+ </div>
137
+ </div>
138
+ );
139
+ };
140
+
141
+ type UniversalConfig = {
142
+ delayMs: number | null;
143
+ };
144
+
145
+ type TimecodeDisplayProps = {
146
+ id: TimecodeInstanceId;
147
+ timecode: TimecodeInstance;
148
+ config: UniversalConfig;
149
+ headerComponents?: React.ReactNode;
150
+ };
151
+
152
+ const TimecodeDisplay: FC<TimecodeDisplayProps> = ({
153
+ id,
154
+ timecode: { state, metadata },
155
+ config,
156
+ headerComponents,
157
+ }) => {
158
+ const { handlers, callHandler } = useApplicationHandlers();
159
+
160
+ const hooks = id && getTreeValue(handlers, id);
161
+
162
+ const play = useCallback(() => {
163
+ if (id) {
164
+ callHandler({ handler: 'play', path: id, args: [] });
165
+ }
166
+ }, [callHandler, id]);
167
+
168
+ const pause = useCallback(() => {
169
+ if (id) {
170
+ callHandler({ handler: 'pause', path: id, args: [] });
171
+ }
172
+ }, [callHandler, id]);
173
+
174
+ const back5seconds = useCallback(() => {
175
+ if (id) {
176
+ callHandler({ handler: 'seekRelative', path: id, args: [-5000] });
177
+ }
178
+ }, [callHandler, id]);
179
+
180
+ const forward5seconds = useCallback(() => {
181
+ if (id) {
182
+ callHandler({ handler: 'seekRelative', path: id, args: [5000] });
183
+ }
184
+ }, [callHandler, id]);
185
+
186
+ const beginning = useCallback(() => {
187
+ if (id) {
188
+ callHandler({ handler: 'beginning', path: id, args: [] });
189
+ }
190
+ }, [callHandler, id]);
191
+
192
+ const toggle = useCallback(() => {
193
+ if (hooks?.play && hooks?.pause) {
194
+ if (state.state === 'none' || state.state === 'stopped') {
195
+ play();
196
+ } else {
197
+ pause();
198
+ }
199
+ }
200
+ }, [hooks, play, pause, state.state]);
201
+
202
+ return (
203
+ <div className="flex grow flex-col gap-px">
204
+ <div
205
+ className={cn(
206
+ 'flex grow flex-col p-0.5',
207
+ cnd(
208
+ state?.state === 'lagging',
209
+ 'bg-sigil-usage-red-background text-sigil-usage-red-text',
210
+ 'bg-sigil-bg-light text-timecode-usage-foreground',
211
+ ),
212
+ )}
213
+ >
214
+ {headerComponents && (
215
+ <div className="flex flex-wrap gap-0.25">{headerComponents}</div>
216
+ )}
217
+ <SizeAwareDiv
218
+ className={cn(
219
+ 'relative min-h-timecode-min-height grow',
220
+ cnd(state?.state === 'stopped', 'opacity-50'),
221
+ cnd(
222
+ hooks?.play && hooks?.pause,
223
+ `
224
+ cursor-pointer
225
+ hover:opacity-100
226
+ `,
227
+ ),
228
+ )}
229
+ onClick={toggle}
230
+ >
231
+ <div className="absolute inset-0 flex items-center justify-center">
232
+ <span className={cn('font-mono text-timecode-adaptive')}>
233
+ {state.state === 'none' ? (
234
+ '--:--:--:---'
235
+ ) : state.state === 'stopped' ? (
236
+ displayMillis(state.positionMillis)
237
+ ) : (
238
+ <ActiveTimecodeText
239
+ effectiveStartTimeMillis={state.effectiveStartTimeMillis}
240
+ speed={state.speed}
241
+ />
242
+ )}
243
+ </span>
244
+ </div>
245
+ </SizeAwareDiv>
246
+ {hooks?.pause || hooks?.play ? (
247
+ <div className="flex justify-center gap-px">
248
+ {hooks.beginning && (
249
+ <ControlButton
250
+ onClick={beginning}
251
+ variant="large"
252
+ icon="skip_previous"
253
+ disabled={!hooks?.beginning}
254
+ title={STRINGS.controls.beginning}
255
+ className="text-timecode-usage-foreground!"
256
+ />
257
+ )}
258
+ {hooks.seekRelative && (
259
+ <ControlButton
260
+ onClick={back5seconds}
261
+ variant="large"
262
+ icon="replay_5"
263
+ disabled={!hooks?.seekRelative}
264
+ title={STRINGS.controls.back5seconds}
265
+ className="text-timecode-usage-foreground!"
266
+ />
267
+ )}
268
+ {state.state === 'none' || state.state === 'stopped' ? (
269
+ <ControlButton
270
+ onClick={play}
271
+ variant="large"
272
+ icon="play_arrow"
273
+ disabled={!hooks?.play}
274
+ title={STRINGS.controls.play}
275
+ className="text-timecode-usage-foreground!"
276
+ />
277
+ ) : (
278
+ <ControlButton
279
+ onClick={pause}
280
+ variant="large"
281
+ icon="pause"
282
+ disabled={!hooks?.pause}
283
+ title={STRINGS.controls.pause}
284
+ className="text-timecode-usage-foreground!"
285
+ />
286
+ )}
287
+ {hooks.seekRelative && (
288
+ <ControlButton
289
+ onClick={forward5seconds}
290
+ variant="large"
291
+ icon="forward_5"
292
+ disabled={!hooks?.seekRelative}
293
+ title={STRINGS.controls.forward5seconds}
294
+ className="text-timecode-usage-foreground!"
295
+ />
296
+ )}
297
+ </div>
298
+ ) : null}
299
+ {metadata?.totalTime && (
300
+ <Timeline state={state} totalTime={metadata.totalTime} />
301
+ )}
302
+ </div>
303
+ {(state.smpteMode !== null ||
304
+ state.accuracyMillis !== null ||
305
+ config.delayMs !== null) && (
306
+ <div className="flex gap-px">
307
+ {config.delayMs !== null && (
308
+ <div className="grow basis-0 truncate bg-sigil-bg-light p-0.5">
309
+ {STRINGS.delay(config.delayMs)}
310
+ </div>
311
+ )}
312
+ {state.smpteMode !== null && (
313
+ <div className="grow basis-0 truncate bg-sigil-bg-light p-0.5">
314
+ {STRINGS.smtpeModes[state.smpteMode]}
315
+ </div>
316
+ )}
317
+ {state.accuracyMillis !== null && (
318
+ <div className="grow basis-0 truncate bg-sigil-bg-light p-0.5">
319
+ {STRINGS.accuracy(state.accuracyMillis)}
320
+ </div>
321
+ )}
322
+ </div>
323
+ )}
324
+ {metadata?.artist || metadata?.title ? (
325
+ <TooltipWrapper
326
+ tooltip={
327
+ <>
328
+ {metadata.title && (
329
+ <div>
330
+ <span className="font-bold">Title:</span> {metadata.title}
331
+ </div>
332
+ )}
333
+ {metadata.artist && (
334
+ <div>
335
+ <span className="font-bold">Artist:</span> {metadata.artist}
336
+ </div>
337
+ )}
338
+ </>
339
+ }
340
+ >
341
+ <div className="flex gap-px">
342
+ {metadata.title && (
343
+ <div className="grow truncate bg-sigil-bg-light p-0.5 font-bold">
344
+ {metadata.title}
345
+ </div>
346
+ )}
347
+ {metadata.artist && (
348
+ <div className="grow truncate bg-sigil-bg-light p-0.5">
349
+ {metadata.artist}
350
+ </div>
351
+ )}
352
+ </div>
353
+ </TooltipWrapper>
354
+ ) : null}
355
+ </div>
356
+ );
357
+ };
358
+
359
+ type TimecodeTreeDisplayProps = {
360
+ config: UniversalConfig;
361
+ /**
362
+ * Outputs will not have this set, inputs and generators will.
363
+ */
364
+ id: TimecodeInstanceId;
365
+ type: string;
366
+ name: string[];
367
+ color: SigilColor | undefined;
368
+ timecode: TimecodeGroup | TimecodeInstance | null;
369
+ namePlaceholder: string;
370
+ buttons: ReactNode;
371
+ /**
372
+ * If set, calling this will assign the instance to the given output on
373
+ */
374
+ assignToOutput: AssignToOutputCallback;
375
+ };
376
+
377
+ const EMPTY_TIMECODE: TimecodeInstance = {
378
+ name: null,
379
+ state: {
380
+ state: 'none',
381
+ accuracyMillis: null,
382
+ smpteMode: null,
383
+ onAir: null,
384
+ },
385
+ metadata: null,
386
+ };
387
+
388
+ const extendId = <T extends TimecodeInstanceId>(id: T, key: string): T => {
389
+ return [id[0], ...id.slice(1), key] as unknown as T;
390
+ };
391
+
392
+ export const TimecodeTreeDisplay: FC<TimecodeTreeDisplayProps> = ({
393
+ config,
394
+ id,
395
+ type,
396
+ name,
397
+ color,
398
+ timecode,
399
+ namePlaceholder,
400
+ buttons,
401
+ assignToOutput,
402
+ }) => {
403
+ const { openNewWidow } = useBrowserContext();
404
+
405
+ const openInNewWindow = useCallback(() => {
406
+ if (id) {
407
+ openNewWidow(withUrlFragment({ values: { tc: id } }).href, {
408
+ canUseExisting: false,
409
+ mode: WINDOW_MODE_TIMECODE,
410
+ });
411
+ }
412
+ }, [id, openNewWidow]);
413
+
414
+ name = timecode?.name ? [...name, timecode.name] : name;
415
+ if (isTimecodeGroup(timecode) && Object.values(timecode.timecodes).length) {
416
+ return Object.entries(timecode.timecodes).map(([key, child]) => (
417
+ <TimecodeTreeDisplay
418
+ config={config}
419
+ id={extendId(id, key)}
420
+ key={key}
421
+ type={type}
422
+ name={name}
423
+ color={timecode.color ?? color}
424
+ timecode={child}
425
+ namePlaceholder={namePlaceholder}
426
+ buttons={buttons}
427
+ assignToOutput={assignToOutput}
428
+ />
429
+ ));
430
+ }
431
+
432
+ return (
433
+ <div
434
+ className="relative flex grow flex-col text-timecode-usage-foreground"
435
+ style={
436
+ color &&
437
+ cssSigilColorUsageVariables('timecode-usage', sigilColorUsage(color))
438
+ }
439
+ >
440
+ <TimecodeDisplay
441
+ id={id}
442
+ timecode={isTimecodeInstance(timecode) ? timecode : EMPTY_TIMECODE}
443
+ config={config}
444
+ headerComponents={
445
+ <>
446
+ <div className="flex grow items-start gap-0.25">
447
+ <div
448
+ className="
449
+ m-0.25 rounded-md border border-sigil-bg-light
450
+ bg-timecode-usage-foreground px-1 py-0.25 text-sigil-control
451
+ text-timecode-usage-text
452
+ "
453
+ >
454
+ {type}
455
+ </div>
456
+ <div
457
+ className={cn(
458
+ 'grow basis-0 truncate p-0.5',
459
+ cnd(name.length, 'font-bold', 'italic opacity-50'),
460
+ )}
461
+ >
462
+ {name.length ? name.join(' / ') : namePlaceholder}
463
+ </div>
464
+ </div>
465
+ <ControlButtonGroup className="rounded-md bg-sigil-bg-light">
466
+ <ControlButton
467
+ variant="toolbar"
468
+ icon="open_in_new"
469
+ title={STRINGS.openInNewWindow}
470
+ onClick={openInNewWindow}
471
+ />
472
+ {buttons}
473
+ </ControlButtonGroup>
474
+ </>
475
+ }
476
+ />
477
+ {assignToOutput && id && !isOutputInstanceId(id) && (
478
+ <SizeAwareDiv
479
+ className="
480
+ absolute inset-0 flex cursor-pointer items-center justify-center
481
+ bg-timecode-backdrop text-timecode-usage-text
482
+ hover:bg-timecode-backdrop-hover
483
+ "
484
+ onClick={() => assignToOutput(id)}
485
+ >
486
+ <Icon icon="link" className="text-block-icon" />
487
+ </SizeAwareDiv>
488
+ )}
489
+ </div>
490
+ );
491
+ };
492
+
493
+ type FullscreenTimecodeConfig = {
494
+ config: UniversalConfig;
495
+ type: string;
496
+ name: string[];
497
+ color: SigilColor | undefined;
498
+ namePlaceholder: string;
499
+ };
500
+
501
+ export const FullscreenTimecodeDisplay: FC<{ id: TimecodeInstanceId }> = ({
502
+ id,
503
+ }) => {
504
+ const { config } = useContext(ConfigContext);
505
+ const applicationState = useContext(ApplicationStateContext);
506
+
507
+ const timecode: TimecodeInstance | null = useMemo(() => {
508
+ if (isInputInstanceId(id) || isGeneratorInstanceId(id)) {
509
+ return getTimecodeInstance(applicationState, id);
510
+ } else {
511
+ const c = config.outputs[id[1]];
512
+ if (!c) {
513
+ return null;
514
+ }
515
+ return augmentUpstreamTimecodeWithOutputMetadata(
516
+ c.link ? getTimecodeInstance(applicationState, c.link) : null,
517
+ c,
518
+ );
519
+ }
520
+ }, [applicationState, id, config.outputs]);
521
+
522
+ const instanceConfig: FullscreenTimecodeConfig | null = useMemo(() => {
523
+ if (isInputInstanceId(id)) {
524
+ const c = config.inputs[id[1]];
525
+ if (!c) {
526
+ return null;
527
+ }
528
+ return {
529
+ config: { delayMs: c.delayMs ?? null },
530
+ type: STRINGS.protocols[c.definition.type].short,
531
+ name: c.name ? [c.name] : [],
532
+ color: c.color,
533
+ namePlaceholder: `Unnamed Input`,
534
+ };
535
+ } else if (isGeneratorInstanceId(id)) {
536
+ const c = config.generators[id[1]];
537
+ if (!c) {
538
+ return null;
539
+ }
540
+ return {
541
+ config: { delayMs: c.delayMs ?? null },
542
+ type: STRINGS.generators.type[c.definition.type],
543
+ name: c.name ? [c.name] : [],
544
+ color: c.color,
545
+ namePlaceholder: `Unnamed Generator`,
546
+ };
547
+ } else {
548
+ const c = config.outputs[id[1]];
549
+ if (!c) {
550
+ return null;
551
+ }
552
+ return {
553
+ config: { delayMs: c.delayMs ?? null },
554
+ type: STRINGS.protocols[c.definition.type].short,
555
+ name: c.name ? [c.name] : [],
556
+ color: c.color,
557
+ namePlaceholder: `Unnamed Output`,
558
+ };
559
+ }
560
+ }, [id, config]);
561
+
562
+ if (!instanceConfig) {
563
+ return (
564
+ <SizeAwareDiv
565
+ className="
566
+ flex grow flex-col items-center justify-center gap-1 bg-sigil-bg-light
567
+ p-1 text-sigil-foreground-muted
568
+ "
569
+ >
570
+ <Icon icon="question_mark" className="text-block-icon" />
571
+ <div className="text-center">{STRINGS.errors.unknownTimecodeID}</div>
572
+ </SizeAwareDiv>
573
+ );
574
+ }
575
+
576
+ return (
577
+ <div
578
+ className="
579
+ flex h-0 grow flex-col gap-px overflow-y-auto bg-sigil-border
580
+ scrollbar-sigil
581
+ "
582
+ >
583
+ <TimecodeTreeDisplay
584
+ id={id}
585
+ timecode={timecode}
586
+ assignToOutput={null}
587
+ buttons={null}
588
+ {...instanceConfig}
589
+ />
590
+ </div>
591
+ );
592
+ };