@vsreact/core 0.0.10 → 0.0.12
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.
- package/build.ts +24 -24
- package/dist/components.d.ts +6 -0
- package/dist/components.js +3 -0
- package/dist/controls.d.ts +7 -2
- package/dist/controls.js +11 -11
- package/dist/feedback.d.ts +4 -1
- package/dist/feedback.js +12 -2
- package/dist/format.d.ts +16 -0
- package/dist/format.js +50 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/keyboard.d.ts +23 -0
- package/dist/keyboard.js +79 -0
- package/dist/sequencer.d.ts +23 -0
- package/dist/sequencer.js +20 -0
- package/dist/specialty.d.ts +109 -0
- package/dist/specialty.js +186 -0
- package/dist/tw.js +5 -0
- package/package.json +64 -64
- package/src/bridge.ts +66 -66
- package/src/components.ts +22 -0
- package/src/controls.test.tsx +109 -0
- package/src/controls.tsx +62 -22
- package/src/feedback.tsx +28 -6
- package/src/format.ts +62 -0
- package/src/hostConfig.test.tsx +120 -120
- package/src/hostConfig.ts +164 -164
- package/src/index.ts +31 -0
- package/src/keyboard.tsx +151 -0
- package/src/native.ts +18 -18
- package/src/perform.test.tsx +188 -0
- package/src/primitives.tsx +95 -95
- package/src/render.tsx +47 -47
- package/src/runtime.ts +92 -92
- package/src/sequencer.tsx +83 -0
- package/src/specialty.tsx +514 -0
- package/src/theme.ts +159 -159
- package/src/tw.test.ts +119 -113
- package/src/tw.ts +288 -284
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The step sequencer grid — rows of cells you click on and off, with a
|
|
2
|
+
// playhead column the host drives. Patterns live in the app's state;
|
|
3
|
+
// the component is fully controlled.
|
|
4
|
+
|
|
5
|
+
import { View, Text } from "./primitives";
|
|
6
|
+
|
|
7
|
+
export interface StepSequencerProps {
|
|
8
|
+
/** rows × steps. `pattern[row][step]` true = lit. */
|
|
9
|
+
pattern: boolean[][];
|
|
10
|
+
/** The column currently playing — painted with a bright ring. */
|
|
11
|
+
playhead?: number;
|
|
12
|
+
/** Printed left of each row ("KICK", "SNARE"…). */
|
|
13
|
+
rowLabels?: string[];
|
|
14
|
+
/** Cell edge length. Default 20. */
|
|
15
|
+
cellSize?: number;
|
|
16
|
+
/** Space between cells. Default 5. */
|
|
17
|
+
gap?: number;
|
|
18
|
+
/** Downbeat tint every N steps. Default 4; 0 disables. */
|
|
19
|
+
groupEvery?: number;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
cellColor?: string;
|
|
22
|
+
/** Downbeat cells (step 0, 4, 8…) when unlit. */
|
|
23
|
+
downbeatColor?: string;
|
|
24
|
+
activeColor?: string;
|
|
25
|
+
playheadColor?: string;
|
|
26
|
+
labelColor?: string;
|
|
27
|
+
onToggle: (row: number, step: number, next: boolean) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function StepSequencer({
|
|
31
|
+
pattern,
|
|
32
|
+
playhead,
|
|
33
|
+
rowLabels,
|
|
34
|
+
cellSize = 20,
|
|
35
|
+
gap = 5,
|
|
36
|
+
groupEvery = 4,
|
|
37
|
+
disabled,
|
|
38
|
+
cellColor = "#242922",
|
|
39
|
+
downbeatColor = "#2E342B",
|
|
40
|
+
activeColor = "#C6F135",
|
|
41
|
+
playheadColor = "#ECF2E8",
|
|
42
|
+
labelColor = "#a1a1aa",
|
|
43
|
+
onToggle,
|
|
44
|
+
}: StepSequencerProps) {
|
|
45
|
+
const labelWidth =
|
|
46
|
+
rowLabels !== undefined && rowLabels.length > 0
|
|
47
|
+
? Math.max(...rowLabels.map((label) => label.length)) * 7 + 10
|
|
48
|
+
: 0;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<View className={disabled ? "opacity-40" : ""} style={{ rowGap: gap }}>
|
|
52
|
+
{pattern.map((row, r) => (
|
|
53
|
+
<View key={r} className="flex-row items-center" style={{ columnGap: gap }}>
|
|
54
|
+
{labelWidth > 0 ? (
|
|
55
|
+
<Text
|
|
56
|
+
className="text-[10] font-bold tracking-widest"
|
|
57
|
+
style={{ width: labelWidth, color: labelColor }}
|
|
58
|
+
>
|
|
59
|
+
{rowLabels?.[r] ?? ""}
|
|
60
|
+
</Text>
|
|
61
|
+
) : null}
|
|
62
|
+
{row.map((lit, s) => (
|
|
63
|
+
<View
|
|
64
|
+
key={s}
|
|
65
|
+
className={`rounded-[4] border ${disabled ? "" : "cursor-pointer"}`}
|
|
66
|
+
style={{
|
|
67
|
+
width: cellSize,
|
|
68
|
+
height: cellSize,
|
|
69
|
+
backgroundColor: lit
|
|
70
|
+
? activeColor
|
|
71
|
+
: groupEvery > 0 && s % groupEvery === 0
|
|
72
|
+
? downbeatColor
|
|
73
|
+
: cellColor,
|
|
74
|
+
borderColor: s === playhead ? playheadColor : "#00000055",
|
|
75
|
+
}}
|
|
76
|
+
onClick={disabled ? undefined : () => onToggle(r, s, !lit)}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
</View>
|
|
80
|
+
))}
|
|
81
|
+
</View>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -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
|
+
}
|