@vsreact/core 0.0.9 → 0.0.11

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.
@@ -0,0 +1,514 @@
1
+ // The flagship-visual tier — Output-style macro pads, hardware knobs,
2
+ // crossfader strips, and value-reactive orbs. Everything is painted
3
+ // natively from Views, arcs, and shadows; the motion runs on the host
4
+ // scheduler.
5
+
6
+ import { useRef, useState } from "react";
7
+ import { View, Text } from "./primitives";
8
+ import { useParameter } from "./parameters";
9
+ import { useInterval } from "./hooks";
10
+
11
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
12
+
13
+ // ── MacroPad ───────────────────────────────────────────────────────────
14
+
15
+ export interface MacroPadProps {
16
+ /** Normalized 0..1. */
17
+ x: number;
18
+ /** Normalized 0..1 — 1 is the top. */
19
+ y: number;
20
+ /** Diameter. Default 220. */
21
+ size?: number;
22
+ labelX?: string;
23
+ labelY?: string;
24
+ disabled?: boolean;
25
+ /** Concentric ring count. Default 9. */
26
+ rings?: number;
27
+ /** Freeze the ring motion (reduced-motion, screenshots). */
28
+ animate?: boolean;
29
+ color?: string;
30
+ trackColor?: string;
31
+ onChange: (x: number, y: number) => void;
32
+ onBegin?: () => void;
33
+ onEnd?: () => void;
34
+ }
35
+
36
+ /** The centerpiece macro control: a circular 2D pad whose concentric
37
+ rings breathe with the values — x spreads them, y drives their
38
+ intensity. One drag, two parameters, instrument-grade presence. */
39
+ export function MacroPad({
40
+ x,
41
+ y,
42
+ size = 220,
43
+ labelX,
44
+ labelY,
45
+ disabled,
46
+ rings = 9,
47
+ animate = true,
48
+ color = "#C6F135",
49
+ trackColor = "#101210",
50
+ onChange,
51
+ onBegin,
52
+ onEnd,
53
+ }: MacroPadProps) {
54
+ const start = useRef({ x: 0, y: 0 });
55
+ const [phase, setPhase] = useState(0);
56
+ useInterval(() => setPhase((p) => (p + 1) % 1000), animate && !disabled ? 50 : null);
57
+
58
+ const cx = clamp01(x);
59
+ const cy = clamp01(y);
60
+ const thumb = 12;
61
+ const tx = cx * (size - thumb);
62
+ const ty = (1 - cy) * (size - thumb);
63
+
64
+ const ringViews = [];
65
+ for (let i = 0; i < rings; i++) {
66
+ const t = (i + 1) / rings;
67
+ // x spreads the rings outward; the phase makes them breathe gently.
68
+ const spread = 0.3 + 0.7 * Math.pow(t, 1.6 - cx * 1.2);
69
+ const breathe = 1 + 0.02 * Math.sin(phase / 6 + i * 0.9);
70
+ const ringSize = Math.min(size - 2, size * spread * breathe);
71
+ // y drives intensity; outer rings fade.
72
+ const opacity = clamp01((0.12 + 0.5 * cy) * (1.15 - t) * (0.8 + 0.2 * Math.sin(phase / 9 + i)));
73
+
74
+ ringViews.push(
75
+ <View
76
+ key={i}
77
+ className="absolute rounded-full border"
78
+ style={{
79
+ width: ringSize,
80
+ height: ringSize,
81
+ left: (size - ringSize) / 2,
82
+ top: (size - ringSize) / 2,
83
+ borderColor: color,
84
+ opacity,
85
+ }}
86
+ />,
87
+ );
88
+ }
89
+
90
+ return (
91
+ <View
92
+ className={`relative rounded-full overflow-hidden border ${disabled ? "opacity-40" : "cursor-pointer"}`}
93
+ style={{ width: size, height: size, backgroundColor: trackColor, borderColor: "#00000066" }}
94
+ onDragStart={
95
+ disabled
96
+ ? undefined
97
+ : () => {
98
+ start.current = { x: cx, y: cy };
99
+ onBegin?.();
100
+ }
101
+ }
102
+ onDrag={
103
+ disabled
104
+ ? undefined
105
+ : (e) => onChange(clamp01(start.current.x + e.dx / size), clamp01(start.current.y - e.dy / size))
106
+ }
107
+ onDragEnd={disabled ? undefined : () => onEnd?.()}
108
+ onDoubleClick={disabled ? undefined : () => onChange(0.5, 0.5)}
109
+ >
110
+ {ringViews}
111
+ <View
112
+ className="absolute rounded-full"
113
+ style={{
114
+ width: thumb,
115
+ height: thumb,
116
+ left: tx,
117
+ top: ty,
118
+ backgroundColor: color,
119
+ shadowColor: color,
120
+ shadowRadius: 10,
121
+ }}
122
+ />
123
+ {labelY !== undefined ? (
124
+ <Text
125
+ className="absolute text-[9] font-bold tracking-widest"
126
+ style={{ left: 10, top: size / 2 - 6, color, opacity: 0.75 }}
127
+ >
128
+ {labelY}
129
+ </Text>
130
+ ) : null}
131
+ {labelX !== undefined ? (
132
+ <Text
133
+ className="absolute text-[9] font-bold tracking-widest"
134
+ style={{ bottom: 10, left: size / 2 - 24, color, opacity: 0.75 }}
135
+ >
136
+ {labelX}
137
+ </Text>
138
+ ) : null}
139
+ </View>
140
+ );
141
+ }
142
+
143
+ export interface ParamMacroPadProps {
144
+ paramX: string;
145
+ paramY: string;
146
+ size?: number;
147
+ labelX?: string;
148
+ labelY?: string;
149
+ rings?: number;
150
+ animate?: boolean;
151
+ color?: string;
152
+ trackColor?: string;
153
+ }
154
+
155
+ /** A MacroPad driving two host parameters — labels default to their
156
+ names. */
157
+ export function ParamMacroPad({ paramX, paramY, labelX, labelY, ...rest }: ParamMacroPadProps) {
158
+ const px = useParameter(paramX);
159
+ const py = useParameter(paramY);
160
+
161
+ return (
162
+ <MacroPad
163
+ x={px.value}
164
+ y={py.value}
165
+ labelX={labelX ?? px.name.toUpperCase()}
166
+ labelY={labelY ?? py.name.toUpperCase()}
167
+ onChange={(nx, ny) => {
168
+ px.set(nx);
169
+ py.set(ny);
170
+ }}
171
+ onBegin={() => {
172
+ px.begin();
173
+ py.begin();
174
+ }}
175
+ onEnd={() => {
176
+ px.end();
177
+ py.end();
178
+ }}
179
+ {...rest}
180
+ />
181
+ );
182
+ }
183
+
184
+ // ── HardwareKnob ───────────────────────────────────────────────────────
185
+
186
+ const HW_START = -135;
187
+ const HW_END = 135;
188
+
189
+ export interface HardwareKnobProps {
190
+ value: number;
191
+ size?: number;
192
+ label?: string;
193
+ text?: string;
194
+ disabled?: boolean;
195
+ defaultValue?: number;
196
+ wheelSensitivity?: number;
197
+ capColor?: string;
198
+ pointerColor?: string;
199
+ tickColor?: string;
200
+ onChange: (value: number) => void;
201
+ onBegin?: () => void;
202
+ onEnd?: () => void;
203
+ }
204
+
205
+ /** The skeuomorphic knob: a dark hardware cap with a glowing pointer
206
+ notch at the rim and a faint tick track — the audio-ui.com look,
207
+ painted natively. */
208
+ export function HardwareKnob({
209
+ value,
210
+ size = 88,
211
+ label,
212
+ text,
213
+ disabled,
214
+ defaultValue,
215
+ wheelSensitivity = 0.4,
216
+ capColor = "#1B1B1A",
217
+ pointerColor = "#C6F135",
218
+ tickColor = "#FFFFFF14",
219
+ onChange,
220
+ onBegin,
221
+ onEnd,
222
+ }: HardwareKnobProps) {
223
+ const startValue = useRef(0);
224
+ const clamped = clamp01(value);
225
+ const angle = HW_START + (HW_END - HW_START) * clamped;
226
+
227
+ const nudge = (target: number) => {
228
+ onBegin?.();
229
+ onChange(clamp01(target));
230
+ onEnd?.();
231
+ };
232
+
233
+ return (
234
+ <View className="items-center gap-2">
235
+ <View
236
+ className={`relative ${disabled ? "opacity-40" : "cursor-pointer"}`}
237
+ style={{
238
+ width: size,
239
+ height: size,
240
+ arcTrackColor: tickColor,
241
+ arcStart: HW_START,
242
+ arcEnd: HW_END,
243
+ arcThickness: 2,
244
+ }}
245
+ onDragStart={
246
+ disabled
247
+ ? undefined
248
+ : () => {
249
+ startValue.current = clamped;
250
+ onBegin?.();
251
+ }
252
+ }
253
+ onDrag={
254
+ disabled
255
+ ? undefined
256
+ : (e) => onChange(clamp01(startValue.current - e.dy * 0.005))
257
+ }
258
+ onDragEnd={disabled ? undefined : () => onEnd?.()}
259
+ onDoubleClick={
260
+ disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue)
261
+ }
262
+ onWheel={
263
+ disabled || wheelSensitivity === 0
264
+ ? undefined
265
+ : (e) => nudge(clamped + e.dy * wheelSensitivity)
266
+ }
267
+ >
268
+ <View
269
+ className="absolute rounded-full border items-center justify-center shadow-lg"
270
+ style={{
271
+ left: size * 0.09,
272
+ top: size * 0.09,
273
+ width: size * 0.82,
274
+ height: size * 0.82,
275
+ backgroundColor: capColor,
276
+ borderColor: "#000000AA",
277
+ }}
278
+ >
279
+ {text !== undefined ? (
280
+ <Text className="text-text font-bold" style={{ fontSize: Math.max(10, size * 0.14) }}>
281
+ {text}
282
+ </Text>
283
+ ) : null}
284
+ </View>
285
+ {/* the pointer: a bright notch riding the rim at the value angle */}
286
+ <View
287
+ className="absolute inset-0"
288
+ style={{
289
+ arcColor: pointerColor,
290
+ arcStart: HW_START,
291
+ arcEnd: HW_END,
292
+ arcValueStart: angle - 4,
293
+ arcValueEnd: angle + 4,
294
+ arcThickness: Math.max(4, size * 0.075),
295
+ }}
296
+ />
297
+ </View>
298
+ {label !== undefined ? (
299
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
300
+ ) : null}
301
+ </View>
302
+ );
303
+ }
304
+
305
+ export interface ParamHardwareKnobProps {
306
+ paramId: string;
307
+ size?: number;
308
+ label?: string;
309
+ capColor?: string;
310
+ pointerColor?: string;
311
+ tickColor?: string;
312
+ wheelSensitivity?: number;
313
+ }
314
+
315
+ /** A HardwareKnob bound to a host parameter. */
316
+ export function ParamHardwareKnob({ paramId, label, ...rest }: ParamHardwareKnobProps) {
317
+ const param = useParameter(paramId);
318
+
319
+ return (
320
+ <HardwareKnob
321
+ value={param.value}
322
+ text={param.text}
323
+ label={label ?? param.name.toUpperCase()}
324
+ defaultValue={param.defaultValue}
325
+ onChange={param.set}
326
+ onBegin={param.begin}
327
+ onEnd={param.end}
328
+ {...rest}
329
+ />
330
+ );
331
+ }
332
+
333
+ // ── Crossfader ─────────────────────────────────────────────────────────
334
+
335
+ export interface CrossfaderProps {
336
+ /** 0 = fully start-side (dry), 1 = fully end-side (wet). */
337
+ value: number;
338
+ width?: number;
339
+ height?: number;
340
+ labelStart?: string;
341
+ labelEnd?: string;
342
+ disabled?: boolean;
343
+ trackColor?: string;
344
+ handleColor?: string;
345
+ textColor?: string;
346
+ onChange: (value: number) => void;
347
+ onBegin?: () => void;
348
+ onEnd?: () => void;
349
+ }
350
+
351
+ /** The DRY/WET strip: a wide track with a grippy rectangular handle. */
352
+ export function Crossfader({
353
+ value,
354
+ width = 220,
355
+ height = 34,
356
+ labelStart = "DRY",
357
+ labelEnd = "WET",
358
+ disabled,
359
+ trackColor = "#141614",
360
+ handleColor = "#2E332C",
361
+ textColor = "#6f6e66",
362
+ onChange,
363
+ onBegin,
364
+ onEnd,
365
+ }: CrossfaderProps) {
366
+ const startValue = useRef(0);
367
+ const clamped = clamp01(value);
368
+ const handleWidth = 26;
369
+ const travel = width - handleWidth - 6;
370
+
371
+ return (
372
+ <View
373
+ className={`relative rounded-lg border justify-center ${disabled ? "opacity-40" : "cursor-pointer"}`}
374
+ style={{ width, height, backgroundColor: trackColor, borderColor: "#00000066" }}
375
+ onDragStart={
376
+ disabled
377
+ ? undefined
378
+ : () => {
379
+ startValue.current = clamped;
380
+ onBegin?.();
381
+ }
382
+ }
383
+ onDrag={disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / travel))}
384
+ onDragEnd={disabled ? undefined : () => onEnd?.()}
385
+ onDoubleClick={disabled ? undefined : () => {
386
+ onBegin?.();
387
+ onChange(0.5);
388
+ onEnd?.();
389
+ }}
390
+ >
391
+ <Text
392
+ className="absolute text-[8] font-bold tracking-widest"
393
+ style={{ left: 7, top: height / 2 - 5, color: textColor }}
394
+ >
395
+ {labelStart}
396
+ </Text>
397
+ <Text
398
+ className="absolute text-[8] font-bold tracking-widest"
399
+ style={{ right: 7, top: height / 2 - 5, color: textColor }}
400
+ >
401
+ {labelEnd}
402
+ </Text>
403
+ <View
404
+ className="absolute rounded-md border flex-row items-center justify-center gap-[3]"
405
+ style={{
406
+ left: 3 + clamped * travel,
407
+ top: 3,
408
+ bottom: 3,
409
+ width: handleWidth,
410
+ backgroundColor: handleColor,
411
+ borderColor: "#00000088",
412
+ }}
413
+ >
414
+ <View className="w-[2] h-[12] rounded-full" style={{ backgroundColor: "#00000066" }} />
415
+ <View className="w-[2] h-[12] rounded-full" style={{ backgroundColor: "#00000066" }} />
416
+ </View>
417
+ </View>
418
+ );
419
+ }
420
+
421
+ export interface ParamCrossfaderProps {
422
+ paramId: string;
423
+ width?: number;
424
+ height?: number;
425
+ labelStart?: string;
426
+ labelEnd?: string;
427
+ trackColor?: string;
428
+ handleColor?: string;
429
+ }
430
+
431
+ /** A Crossfader bound to a host parameter — the classic mix control. */
432
+ export function ParamCrossfader({ paramId, ...rest }: ParamCrossfaderProps) {
433
+ const param = useParameter(paramId);
434
+
435
+ return (
436
+ <Crossfader
437
+ value={param.value}
438
+ onChange={param.set}
439
+ onBegin={param.begin}
440
+ onEnd={param.end}
441
+ {...rest}
442
+ />
443
+ );
444
+ }
445
+
446
+ // ── PulseOrb ───────────────────────────────────────────────────────────
447
+
448
+ export interface PulseOrbProps {
449
+ /** Level 0..1 — drives ring intensity and core glow. */
450
+ value: number;
451
+ size?: number;
452
+ color?: string;
453
+ /** Echo ring count. Default 4. */
454
+ rings?: number;
455
+ /** Freeze the motion. */
456
+ animate?: boolean;
457
+ }
458
+
459
+ /** A value-reactive orb: a glowing core emitting echo rings — visual
460
+ feedback for levels, activity, or just presence. */
461
+ export function PulseOrb({
462
+ value,
463
+ size = 120,
464
+ color = "#C6F135",
465
+ rings = 4,
466
+ animate = true,
467
+ }: PulseOrbProps) {
468
+ const [phase, setPhase] = useState(0);
469
+ useInterval(() => setPhase((p) => (p + 1) % 1000), animate ? 40 : null);
470
+
471
+ const level = clamp01(value);
472
+ const core = 14 + level * 12;
473
+
474
+ const echoes = [];
475
+ for (let i = 0; i < rings; i++) {
476
+ const t = ((phase * (1 + level * 2) + (i * 100) / rings) % 100) / 100;
477
+ const ringSize = core + t * (size - core - 4);
478
+ const opacity = clamp01((1 - t) * (0.15 + 0.6 * level));
479
+
480
+ echoes.push(
481
+ <View
482
+ key={i}
483
+ className="absolute rounded-full border"
484
+ style={{
485
+ width: ringSize,
486
+ height: ringSize,
487
+ left: (size - ringSize) / 2,
488
+ top: (size - ringSize) / 2,
489
+ borderColor: color,
490
+ opacity,
491
+ }}
492
+ />,
493
+ );
494
+ }
495
+
496
+ return (
497
+ <View className="relative" style={{ width: size, height: size }}>
498
+ {echoes}
499
+ <View
500
+ className="absolute rounded-full"
501
+ style={{
502
+ width: core,
503
+ height: core,
504
+ left: (size - core) / 2,
505
+ top: (size - core) / 2,
506
+ backgroundColor: color,
507
+ shadowColor: color,
508
+ shadowRadius: 6 + level * 14,
509
+ opacity: 0.55 + 0.45 * level,
510
+ }}
511
+ />
512
+ </View>
513
+ );
514
+ }