@vsreact/core 0.0.10 → 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.
- package/dist/components.d.ts +2 -0
- package/dist/components.js +1 -0
- package/dist/controls.d.ts +7 -2
- package/dist/controls.js +11 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/specialty.d.ts +109 -0
- package/dist/specialty.js +186 -0
- package/package.json +1 -1
- package/src/components.ts +18 -0
- package/src/controls.test.tsx +109 -0
- package/src/controls.tsx +62 -22
- package/src/index.ts +18 -0
- package/src/specialty.tsx +514 -0
package/dist/components.d.ts
CHANGED
|
@@ -12,3 +12,5 @@ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRa
|
|
|
12
12
|
export type { NumberBoxProps, CheckboxProps, RadioGroupProps, ParamNumberBoxProps, ParamCheckboxProps, ParamRadioGroupProps, } from "./fields";
|
|
13
13
|
export { ProgressBar, Spinner } from "./feedback";
|
|
14
14
|
export type { ProgressBarProps, SpinnerProps } from "./feedback";
|
|
15
|
+
export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
|
|
16
|
+
export type { MacroPadProps, ParamMacroPadProps, HardwareKnobProps, ParamHardwareKnobProps, CrossfaderProps, ParamCrossfaderProps, PulseOrbProps, } from "./specialty";
|
package/dist/components.js
CHANGED
|
@@ -12,3 +12,4 @@ export { Button } from "./button";
|
|
|
12
12
|
export { Tooltip, Modal } from "./popover";
|
|
13
13
|
export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
|
|
14
14
|
export { ProgressBar, Spinner } from "./feedback";
|
|
15
|
+
export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
|
package/dist/controls.d.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface SliderProps {
|
|
|
40
40
|
height?: number;
|
|
41
41
|
/** Vertical fader — drag up for more, fill rises from the bottom. */
|
|
42
42
|
vertical?: boolean;
|
|
43
|
+
/** Flat hardware-style bar thumb instead of the dot. */
|
|
44
|
+
barThumb?: boolean;
|
|
43
45
|
label?: string;
|
|
44
46
|
disabled?: boolean;
|
|
45
47
|
/** Double-click resets to this (DAW convention). */
|
|
@@ -52,7 +54,7 @@ export interface SliderProps {
|
|
|
52
54
|
onBegin?: () => void;
|
|
53
55
|
onEnd?: () => void;
|
|
54
56
|
}
|
|
55
|
-
export declare function Slider({ value, width, height, vertical, label, disabled, defaultValue, wheelSensitivity, trackColor, valueColor, onChange, onBegin, onEnd, }: SliderProps): import("react").JSX.Element;
|
|
57
|
+
export declare function Slider({ value, width, height, vertical, barThumb, label, disabled, defaultValue, wheelSensitivity, trackColor, valueColor, onChange, onBegin, onEnd, }: SliderProps): import("react").JSX.Element;
|
|
56
58
|
export interface ParamSliderProps {
|
|
57
59
|
paramId: string;
|
|
58
60
|
label?: string;
|
|
@@ -67,6 +69,9 @@ export declare function ParamSlider({ paramId, label, ...rest }: ParamSliderProp
|
|
|
67
69
|
export interface ToggleProps {
|
|
68
70
|
on: boolean;
|
|
69
71
|
label?: string;
|
|
72
|
+
/** Side captions, hardware style: <Toggle offLabel="OFF" onLabel="ON" />. */
|
|
73
|
+
offLabel?: string;
|
|
74
|
+
onLabel?: string;
|
|
70
75
|
/** Track height; width is 1.8×. Default 22. */
|
|
71
76
|
size?: number;
|
|
72
77
|
disabled?: boolean;
|
|
@@ -76,7 +81,7 @@ export interface ToggleProps {
|
|
|
76
81
|
onChange: (on: boolean) => void;
|
|
77
82
|
}
|
|
78
83
|
/** A switch with a spring-animated thumb — bypass, on/off, A/B. */
|
|
79
|
-
export declare function Toggle({ on, label, size, disabled, trackColor, onColor, thumbColor, onChange, }: ToggleProps): import("react").JSX.Element;
|
|
84
|
+
export declare function Toggle({ on, label, offLabel, onLabel, size, disabled, trackColor, onColor, thumbColor, onChange, }: ToggleProps): import("react").JSX.Element;
|
|
80
85
|
export interface ParamToggleProps {
|
|
81
86
|
paramId: string;
|
|
82
87
|
label?: string;
|
package/dist/controls.js
CHANGED
|
@@ -50,7 +50,7 @@ export function ParamKnob({ paramId, label, ...rest }) {
|
|
|
50
50
|
const param = useParameter(paramId);
|
|
51
51
|
return (_jsx(Knob, { value: param.value, text: param.text, label: label ?? param.name.toUpperCase(), defaultValue: param.defaultValue, onChange: param.set, onBegin: param.begin, onEnd: param.end, ...rest }));
|
|
52
52
|
}
|
|
53
|
-
export function Slider({ value, width = 160, height = 160, vertical, label, disabled, defaultValue, wheelSensitivity = 0.4, trackColor = "#2A2F27", valueColor = "#C6F135", onChange, onBegin, onEnd, }) {
|
|
53
|
+
export function Slider({ value, width = 160, height = 160, vertical, barThumb, label, disabled, defaultValue, wheelSensitivity = 0.4, trackColor = "#2A2F27", valueColor = "#C6F135", onChange, onBegin, onEnd, }) {
|
|
54
54
|
const startValue = useRef(0);
|
|
55
55
|
const clamped = clamp01(value);
|
|
56
56
|
const onDragStart = disabled
|
|
@@ -70,9 +70,9 @@ export function Slider({ value, width = 160, height = 160, vertical, label, disa
|
|
|
70
70
|
? undefined
|
|
71
71
|
: (e) => nudge(clamped + e.dy * wheelSensitivity);
|
|
72
72
|
if (vertical) {
|
|
73
|
-
return (_jsxs(View, { className: "items-center gap-2", children: [_jsxs(View, { className: `w-[18] relative ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { height }, onDragStart: onDragStart, onDrag: disabled ? undefined : (e) => onChange(clamp01(startValue.current - e.dy / height)), onDragEnd: onDragEnd, onDoubleClick: onDoubleClick, onWheel: onWheel, children: [_jsx(View, { className: "absolute w-[4] rounded-full left-[7] top-0 bottom-0", style: { backgroundColor: trackColor } }), _jsx(View, { className: "absolute w-[4] rounded-full left-[7] bottom-0", style: { height: clamped * height, backgroundColor: valueColor } }), _jsx(View, { className: "absolute w-[12] h-[12] rounded-full left-[3]", style: { top: (1 - clamped) * (height - 12), backgroundColor: valueColor } })] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
|
|
73
|
+
return (_jsxs(View, { className: "items-center gap-2", children: [_jsxs(View, { className: `w-[18] relative ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { height }, onDragStart: onDragStart, onDrag: disabled ? undefined : (e) => onChange(clamp01(startValue.current - e.dy / height)), onDragEnd: onDragEnd, onDoubleClick: onDoubleClick, onWheel: onWheel, children: [_jsx(View, { className: "absolute w-[4] rounded-full left-[7] top-0 bottom-0", style: { backgroundColor: trackColor } }), _jsx(View, { className: "absolute w-[4] rounded-full left-[7] bottom-0", style: { height: clamped * height, backgroundColor: valueColor } }), barThumb ? (_jsx(View, { className: "absolute w-[18] h-[5] rounded-[2] left-0", style: { top: (1 - clamped) * (height - 5), backgroundColor: valueColor } })) : (_jsx(View, { className: "absolute w-[12] h-[12] rounded-full left-[3]", style: { top: (1 - clamped) * (height - 12), backgroundColor: valueColor } }))] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
|
|
74
74
|
}
|
|
75
|
-
return (_jsxs(View, { className: "gap-2", children: [_jsxs(View, { className: `h-[18] justify-center relative ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width }, onDragStart: onDragStart, onDrag: disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / width)), onDragEnd: onDragEnd, onDoubleClick: onDoubleClick, onWheel: onWheel, children: [_jsx(View, { className: "h-[4] rounded-full", style: { backgroundColor: trackColor } }), _jsx(View, { className: "absolute h-[4] rounded-full top-[7]", style: { width: clamped * width, backgroundColor: valueColor } }), _jsx(View, { className: "absolute w-[12] h-[12] rounded-full top-[3]", style: { left: clamped * (width - 12), backgroundColor: valueColor } })] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
|
|
75
|
+
return (_jsxs(View, { className: "gap-2", children: [_jsxs(View, { className: `h-[18] justify-center relative ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width }, onDragStart: onDragStart, onDrag: disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / width)), onDragEnd: onDragEnd, onDoubleClick: onDoubleClick, onWheel: onWheel, children: [_jsx(View, { className: "h-[4] rounded-full", style: { backgroundColor: trackColor } }), _jsx(View, { className: "absolute h-[4] rounded-full top-[7]", style: { width: clamped * width, backgroundColor: valueColor } }), barThumb ? (_jsx(View, { className: "absolute w-[5] h-[18] rounded-[2] top-0", style: { left: clamped * (width - 5), backgroundColor: valueColor } })) : (_jsx(View, { className: "absolute w-[12] h-[12] rounded-full top-[3]", style: { left: clamped * (width - 12), backgroundColor: valueColor } }))] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
|
|
76
76
|
}
|
|
77
77
|
/** A Slider bound to an APVTS parameter — double-click resets to the
|
|
78
78
|
host's default, the wheel nudges, gestures stay automation-safe. */
|
|
@@ -81,18 +81,18 @@ export function ParamSlider({ paramId, label, ...rest }) {
|
|
|
81
81
|
return (_jsx(Slider, { value: param.value, label: label ?? param.name.toUpperCase(), defaultValue: param.defaultValue, onChange: param.set, onBegin: param.begin, onEnd: param.end, ...rest }));
|
|
82
82
|
}
|
|
83
83
|
/** A switch with a spring-animated thumb — bypass, on/off, A/B. */
|
|
84
|
-
export function Toggle({ on, label, size = 22, disabled, trackColor = "#2A2F27", onColor = "#C6F135", thumbColor = "#F4F4F5", onChange, }) {
|
|
84
|
+
export function Toggle({ on, label, offLabel, onLabel, size = 22, disabled, trackColor = "#2A2F27", onColor = "#C6F135", thumbColor = "#F4F4F5", onChange, }) {
|
|
85
85
|
const t = useSpring(on ? 1 : 0, { stiffness: 300, damping: 28 });
|
|
86
86
|
const trackWidth = Math.round(size * 1.8);
|
|
87
87
|
const thumb = size - 6;
|
|
88
88
|
const travel = trackWidth - thumb - 6;
|
|
89
|
-
return (_jsxs(View, { className: "items-center gap-2", children: [_jsx(View, { className: `relative rounded-full ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width: trackWidth, height: size, backgroundColor: on ? onColor : trackColor }, onClick: disabled ? undefined : () => onChange(!on), children: _jsx(View, { className: "absolute rounded-full", style: {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
return (_jsxs(View, { className: "items-center gap-2", children: [_jsxs(View, { className: "flex-row items-center gap-2", children: [offLabel !== undefined ? (_jsx(Text, { className: "text-[9] font-bold tracking-widest", style: { color: on ? "#6f6e66" : "#ECF2E8" }, children: offLabel })) : null, _jsx(View, { className: `relative rounded-full ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width: trackWidth, height: size, backgroundColor: on ? onColor : trackColor }, onClick: disabled ? undefined : () => onChange(!on), children: _jsx(View, { className: "absolute rounded-full", style: {
|
|
90
|
+
width: thumb,
|
|
91
|
+
height: thumb,
|
|
92
|
+
top: 3,
|
|
93
|
+
left: 3 + clamp01(t) * travel,
|
|
94
|
+
backgroundColor: thumbColor,
|
|
95
|
+
} }) }), onLabel !== undefined ? (_jsx(Text, { className: "text-[9] font-bold tracking-widest", style: { color: on ? "#ECF2E8" : "#6f6e66" }, children: onLabel })) : null] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
|
|
96
96
|
}
|
|
97
97
|
/** A Toggle bound to a bool-style APVTS parameter (on = value ≥ 0.5). */
|
|
98
98
|
export function ParamToggle({ paramId, label, ...rest }) {
|
package/dist/index.d.ts
CHANGED
|
@@ -28,4 +28,6 @@ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRa
|
|
|
28
28
|
export type { NumberBoxProps, CheckboxProps, RadioGroupProps, ParamNumberBoxProps, ParamCheckboxProps, ParamRadioGroupProps, } from "./fields";
|
|
29
29
|
export { ProgressBar, Spinner } from "./feedback";
|
|
30
30
|
export type { ProgressBarProps, SpinnerProps } from "./feedback";
|
|
31
|
+
export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
|
|
32
|
+
export type { MacroPadProps, ParamMacroPadProps, HardwareKnobProps, ParamHardwareKnobProps, CrossfaderProps, ParamCrossfaderProps, PulseOrbProps, } from "./specialty";
|
|
31
33
|
export type { DragEventPayload, LayoutRect, WheelEventPayload } from "./primitives";
|
package/dist/index.js
CHANGED
|
@@ -18,3 +18,4 @@ export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKno
|
|
|
18
18
|
export { Meter, usePeakHold, peakHoldStep } from "./meter";
|
|
19
19
|
export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
|
|
20
20
|
export { ProgressBar, Spinner } from "./feedback";
|
|
21
|
+
export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export interface MacroPadProps {
|
|
2
|
+
/** Normalized 0..1. */
|
|
3
|
+
x: number;
|
|
4
|
+
/** Normalized 0..1 — 1 is the top. */
|
|
5
|
+
y: number;
|
|
6
|
+
/** Diameter. Default 220. */
|
|
7
|
+
size?: number;
|
|
8
|
+
labelX?: string;
|
|
9
|
+
labelY?: string;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
/** Concentric ring count. Default 9. */
|
|
12
|
+
rings?: number;
|
|
13
|
+
/** Freeze the ring motion (reduced-motion, screenshots). */
|
|
14
|
+
animate?: boolean;
|
|
15
|
+
color?: string;
|
|
16
|
+
trackColor?: string;
|
|
17
|
+
onChange: (x: number, y: number) => void;
|
|
18
|
+
onBegin?: () => void;
|
|
19
|
+
onEnd?: () => void;
|
|
20
|
+
}
|
|
21
|
+
/** The centerpiece macro control: a circular 2D pad whose concentric
|
|
22
|
+
rings breathe with the values — x spreads them, y drives their
|
|
23
|
+
intensity. One drag, two parameters, instrument-grade presence. */
|
|
24
|
+
export declare function MacroPad({ x, y, size, labelX, labelY, disabled, rings, animate, color, trackColor, onChange, onBegin, onEnd, }: MacroPadProps): import("react").JSX.Element;
|
|
25
|
+
export interface ParamMacroPadProps {
|
|
26
|
+
paramX: string;
|
|
27
|
+
paramY: string;
|
|
28
|
+
size?: number;
|
|
29
|
+
labelX?: string;
|
|
30
|
+
labelY?: string;
|
|
31
|
+
rings?: number;
|
|
32
|
+
animate?: boolean;
|
|
33
|
+
color?: string;
|
|
34
|
+
trackColor?: string;
|
|
35
|
+
}
|
|
36
|
+
/** A MacroPad driving two host parameters — labels default to their
|
|
37
|
+
names. */
|
|
38
|
+
export declare function ParamMacroPad({ paramX, paramY, labelX, labelY, ...rest }: ParamMacroPadProps): import("react").JSX.Element;
|
|
39
|
+
export interface HardwareKnobProps {
|
|
40
|
+
value: number;
|
|
41
|
+
size?: number;
|
|
42
|
+
label?: string;
|
|
43
|
+
text?: string;
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
defaultValue?: number;
|
|
46
|
+
wheelSensitivity?: number;
|
|
47
|
+
capColor?: string;
|
|
48
|
+
pointerColor?: string;
|
|
49
|
+
tickColor?: string;
|
|
50
|
+
onChange: (value: number) => void;
|
|
51
|
+
onBegin?: () => void;
|
|
52
|
+
onEnd?: () => void;
|
|
53
|
+
}
|
|
54
|
+
/** The skeuomorphic knob: a dark hardware cap with a glowing pointer
|
|
55
|
+
notch at the rim and a faint tick track — the audio-ui.com look,
|
|
56
|
+
painted natively. */
|
|
57
|
+
export declare function HardwareKnob({ value, size, label, text, disabled, defaultValue, wheelSensitivity, capColor, pointerColor, tickColor, onChange, onBegin, onEnd, }: HardwareKnobProps): import("react").JSX.Element;
|
|
58
|
+
export interface ParamHardwareKnobProps {
|
|
59
|
+
paramId: string;
|
|
60
|
+
size?: number;
|
|
61
|
+
label?: string;
|
|
62
|
+
capColor?: string;
|
|
63
|
+
pointerColor?: string;
|
|
64
|
+
tickColor?: string;
|
|
65
|
+
wheelSensitivity?: number;
|
|
66
|
+
}
|
|
67
|
+
/** A HardwareKnob bound to a host parameter. */
|
|
68
|
+
export declare function ParamHardwareKnob({ paramId, label, ...rest }: ParamHardwareKnobProps): import("react").JSX.Element;
|
|
69
|
+
export interface CrossfaderProps {
|
|
70
|
+
/** 0 = fully start-side (dry), 1 = fully end-side (wet). */
|
|
71
|
+
value: number;
|
|
72
|
+
width?: number;
|
|
73
|
+
height?: number;
|
|
74
|
+
labelStart?: string;
|
|
75
|
+
labelEnd?: string;
|
|
76
|
+
disabled?: boolean;
|
|
77
|
+
trackColor?: string;
|
|
78
|
+
handleColor?: string;
|
|
79
|
+
textColor?: string;
|
|
80
|
+
onChange: (value: number) => void;
|
|
81
|
+
onBegin?: () => void;
|
|
82
|
+
onEnd?: () => void;
|
|
83
|
+
}
|
|
84
|
+
/** The DRY/WET strip: a wide track with a grippy rectangular handle. */
|
|
85
|
+
export declare function Crossfader({ value, width, height, labelStart, labelEnd, disabled, trackColor, handleColor, textColor, onChange, onBegin, onEnd, }: CrossfaderProps): import("react").JSX.Element;
|
|
86
|
+
export interface ParamCrossfaderProps {
|
|
87
|
+
paramId: string;
|
|
88
|
+
width?: number;
|
|
89
|
+
height?: number;
|
|
90
|
+
labelStart?: string;
|
|
91
|
+
labelEnd?: string;
|
|
92
|
+
trackColor?: string;
|
|
93
|
+
handleColor?: string;
|
|
94
|
+
}
|
|
95
|
+
/** A Crossfader bound to a host parameter — the classic mix control. */
|
|
96
|
+
export declare function ParamCrossfader({ paramId, ...rest }: ParamCrossfaderProps): import("react").JSX.Element;
|
|
97
|
+
export interface PulseOrbProps {
|
|
98
|
+
/** Level 0..1 — drives ring intensity and core glow. */
|
|
99
|
+
value: number;
|
|
100
|
+
size?: number;
|
|
101
|
+
color?: string;
|
|
102
|
+
/** Echo ring count. Default 4. */
|
|
103
|
+
rings?: number;
|
|
104
|
+
/** Freeze the motion. */
|
|
105
|
+
animate?: boolean;
|
|
106
|
+
}
|
|
107
|
+
/** A value-reactive orb: a glowing core emitting echo rings — visual
|
|
108
|
+
feedback for levels, activity, or just presence. */
|
|
109
|
+
export declare function PulseOrb({ value, size, color, rings, animate, }: PulseOrbProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The flagship-visual tier — Output-style macro pads, hardware knobs,
|
|
3
|
+
// crossfader strips, and value-reactive orbs. Everything is painted
|
|
4
|
+
// natively from Views, arcs, and shadows; the motion runs on the host
|
|
5
|
+
// scheduler.
|
|
6
|
+
import { useRef, useState } from "react";
|
|
7
|
+
import { View, Text } from "./primitives";
|
|
8
|
+
import { useParameter } from "./parameters";
|
|
9
|
+
import { useInterval } from "./hooks";
|
|
10
|
+
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
11
|
+
/** The centerpiece macro control: a circular 2D pad whose concentric
|
|
12
|
+
rings breathe with the values — x spreads them, y drives their
|
|
13
|
+
intensity. One drag, two parameters, instrument-grade presence. */
|
|
14
|
+
export function MacroPad({ x, y, size = 220, labelX, labelY, disabled, rings = 9, animate = true, color = "#C6F135", trackColor = "#101210", onChange, onBegin, onEnd, }) {
|
|
15
|
+
const start = useRef({ x: 0, y: 0 });
|
|
16
|
+
const [phase, setPhase] = useState(0);
|
|
17
|
+
useInterval(() => setPhase((p) => (p + 1) % 1000), animate && !disabled ? 50 : null);
|
|
18
|
+
const cx = clamp01(x);
|
|
19
|
+
const cy = clamp01(y);
|
|
20
|
+
const thumb = 12;
|
|
21
|
+
const tx = cx * (size - thumb);
|
|
22
|
+
const ty = (1 - cy) * (size - thumb);
|
|
23
|
+
const ringViews = [];
|
|
24
|
+
for (let i = 0; i < rings; i++) {
|
|
25
|
+
const t = (i + 1) / rings;
|
|
26
|
+
// x spreads the rings outward; the phase makes them breathe gently.
|
|
27
|
+
const spread = 0.3 + 0.7 * Math.pow(t, 1.6 - cx * 1.2);
|
|
28
|
+
const breathe = 1 + 0.02 * Math.sin(phase / 6 + i * 0.9);
|
|
29
|
+
const ringSize = Math.min(size - 2, size * spread * breathe);
|
|
30
|
+
// y drives intensity; outer rings fade.
|
|
31
|
+
const opacity = clamp01((0.12 + 0.5 * cy) * (1.15 - t) * (0.8 + 0.2 * Math.sin(phase / 9 + i)));
|
|
32
|
+
ringViews.push(_jsx(View, { className: "absolute rounded-full border", style: {
|
|
33
|
+
width: ringSize,
|
|
34
|
+
height: ringSize,
|
|
35
|
+
left: (size - ringSize) / 2,
|
|
36
|
+
top: (size - ringSize) / 2,
|
|
37
|
+
borderColor: color,
|
|
38
|
+
opacity,
|
|
39
|
+
} }, i));
|
|
40
|
+
}
|
|
41
|
+
return (_jsxs(View, { className: `relative rounded-full overflow-hidden border ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width: size, height: size, backgroundColor: trackColor, borderColor: "#00000066" }, onDragStart: disabled
|
|
42
|
+
? undefined
|
|
43
|
+
: () => {
|
|
44
|
+
start.current = { x: cx, y: cy };
|
|
45
|
+
onBegin?.();
|
|
46
|
+
}, onDrag: disabled
|
|
47
|
+
? undefined
|
|
48
|
+
: (e) => onChange(clamp01(start.current.x + e.dx / size), clamp01(start.current.y - e.dy / size)), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled ? undefined : () => onChange(0.5, 0.5), children: [ringViews, _jsx(View, { className: "absolute rounded-full", style: {
|
|
49
|
+
width: thumb,
|
|
50
|
+
height: thumb,
|
|
51
|
+
left: tx,
|
|
52
|
+
top: ty,
|
|
53
|
+
backgroundColor: color,
|
|
54
|
+
shadowColor: color,
|
|
55
|
+
shadowRadius: 10,
|
|
56
|
+
} }), labelY !== undefined ? (_jsx(Text, { className: "absolute text-[9] font-bold tracking-widest", style: { left: 10, top: size / 2 - 6, color, opacity: 0.75 }, children: labelY })) : null, labelX !== undefined ? (_jsx(Text, { className: "absolute text-[9] font-bold tracking-widest", style: { bottom: 10, left: size / 2 - 24, color, opacity: 0.75 }, children: labelX })) : null] }));
|
|
57
|
+
}
|
|
58
|
+
/** A MacroPad driving two host parameters — labels default to their
|
|
59
|
+
names. */
|
|
60
|
+
export function ParamMacroPad({ paramX, paramY, labelX, labelY, ...rest }) {
|
|
61
|
+
const px = useParameter(paramX);
|
|
62
|
+
const py = useParameter(paramY);
|
|
63
|
+
return (_jsx(MacroPad, { x: px.value, y: py.value, labelX: labelX ?? px.name.toUpperCase(), labelY: labelY ?? py.name.toUpperCase(), onChange: (nx, ny) => {
|
|
64
|
+
px.set(nx);
|
|
65
|
+
py.set(ny);
|
|
66
|
+
}, onBegin: () => {
|
|
67
|
+
px.begin();
|
|
68
|
+
py.begin();
|
|
69
|
+
}, onEnd: () => {
|
|
70
|
+
px.end();
|
|
71
|
+
py.end();
|
|
72
|
+
}, ...rest }));
|
|
73
|
+
}
|
|
74
|
+
// ── HardwareKnob ───────────────────────────────────────────────────────
|
|
75
|
+
const HW_START = -135;
|
|
76
|
+
const HW_END = 135;
|
|
77
|
+
/** The skeuomorphic knob: a dark hardware cap with a glowing pointer
|
|
78
|
+
notch at the rim and a faint tick track — the audio-ui.com look,
|
|
79
|
+
painted natively. */
|
|
80
|
+
export function HardwareKnob({ value, size = 88, label, text, disabled, defaultValue, wheelSensitivity = 0.4, capColor = "#1B1B1A", pointerColor = "#C6F135", tickColor = "#FFFFFF14", onChange, onBegin, onEnd, }) {
|
|
81
|
+
const startValue = useRef(0);
|
|
82
|
+
const clamped = clamp01(value);
|
|
83
|
+
const angle = HW_START + (HW_END - HW_START) * clamped;
|
|
84
|
+
const nudge = (target) => {
|
|
85
|
+
onBegin?.();
|
|
86
|
+
onChange(clamp01(target));
|
|
87
|
+
onEnd?.();
|
|
88
|
+
};
|
|
89
|
+
return (_jsxs(View, { className: "items-center gap-2", children: [_jsxs(View, { className: `relative ${disabled ? "opacity-40" : "cursor-pointer"}`, style: {
|
|
90
|
+
width: size,
|
|
91
|
+
height: size,
|
|
92
|
+
arcTrackColor: tickColor,
|
|
93
|
+
arcStart: HW_START,
|
|
94
|
+
arcEnd: HW_END,
|
|
95
|
+
arcThickness: 2,
|
|
96
|
+
}, onDragStart: disabled
|
|
97
|
+
? undefined
|
|
98
|
+
: () => {
|
|
99
|
+
startValue.current = clamped;
|
|
100
|
+
onBegin?.();
|
|
101
|
+
}, onDrag: disabled
|
|
102
|
+
? undefined
|
|
103
|
+
: (e) => onChange(clamp01(startValue.current - e.dy * 0.005)), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue), onWheel: disabled || wheelSensitivity === 0
|
|
104
|
+
? undefined
|
|
105
|
+
: (e) => nudge(clamped + e.dy * wheelSensitivity), children: [_jsx(View, { className: "absolute rounded-full border items-center justify-center shadow-lg", style: {
|
|
106
|
+
left: size * 0.09,
|
|
107
|
+
top: size * 0.09,
|
|
108
|
+
width: size * 0.82,
|
|
109
|
+
height: size * 0.82,
|
|
110
|
+
backgroundColor: capColor,
|
|
111
|
+
borderColor: "#000000AA",
|
|
112
|
+
}, children: text !== undefined ? (_jsx(Text, { className: "text-text font-bold", style: { fontSize: Math.max(10, size * 0.14) }, children: text })) : null }), _jsx(View, { className: "absolute inset-0", style: {
|
|
113
|
+
arcColor: pointerColor,
|
|
114
|
+
arcStart: HW_START,
|
|
115
|
+
arcEnd: HW_END,
|
|
116
|
+
arcValueStart: angle - 4,
|
|
117
|
+
arcValueEnd: angle + 4,
|
|
118
|
+
arcThickness: Math.max(4, size * 0.075),
|
|
119
|
+
} })] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
|
|
120
|
+
}
|
|
121
|
+
/** A HardwareKnob bound to a host parameter. */
|
|
122
|
+
export function ParamHardwareKnob({ paramId, label, ...rest }) {
|
|
123
|
+
const param = useParameter(paramId);
|
|
124
|
+
return (_jsx(HardwareKnob, { value: param.value, text: param.text, label: label ?? param.name.toUpperCase(), defaultValue: param.defaultValue, onChange: param.set, onBegin: param.begin, onEnd: param.end, ...rest }));
|
|
125
|
+
}
|
|
126
|
+
/** The DRY/WET strip: a wide track with a grippy rectangular handle. */
|
|
127
|
+
export function Crossfader({ value, width = 220, height = 34, labelStart = "DRY", labelEnd = "WET", disabled, trackColor = "#141614", handleColor = "#2E332C", textColor = "#6f6e66", onChange, onBegin, onEnd, }) {
|
|
128
|
+
const startValue = useRef(0);
|
|
129
|
+
const clamped = clamp01(value);
|
|
130
|
+
const handleWidth = 26;
|
|
131
|
+
const travel = width - handleWidth - 6;
|
|
132
|
+
return (_jsxs(View, { className: `relative rounded-lg border justify-center ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width, height, backgroundColor: trackColor, borderColor: "#00000066" }, onDragStart: disabled
|
|
133
|
+
? undefined
|
|
134
|
+
: () => {
|
|
135
|
+
startValue.current = clamped;
|
|
136
|
+
onBegin?.();
|
|
137
|
+
}, onDrag: disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / travel)), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled ? undefined : () => {
|
|
138
|
+
onBegin?.();
|
|
139
|
+
onChange(0.5);
|
|
140
|
+
onEnd?.();
|
|
141
|
+
}, children: [_jsx(Text, { className: "absolute text-[8] font-bold tracking-widest", style: { left: 7, top: height / 2 - 5, color: textColor }, children: labelStart }), _jsx(Text, { className: "absolute text-[8] font-bold tracking-widest", style: { right: 7, top: height / 2 - 5, color: textColor }, children: labelEnd }), _jsxs(View, { className: "absolute rounded-md border flex-row items-center justify-center gap-[3]", style: {
|
|
142
|
+
left: 3 + clamped * travel,
|
|
143
|
+
top: 3,
|
|
144
|
+
bottom: 3,
|
|
145
|
+
width: handleWidth,
|
|
146
|
+
backgroundColor: handleColor,
|
|
147
|
+
borderColor: "#00000088",
|
|
148
|
+
}, children: [_jsx(View, { className: "w-[2] h-[12] rounded-full", style: { backgroundColor: "#00000066" } }), _jsx(View, { className: "w-[2] h-[12] rounded-full", style: { backgroundColor: "#00000066" } })] })] }));
|
|
149
|
+
}
|
|
150
|
+
/** A Crossfader bound to a host parameter — the classic mix control. */
|
|
151
|
+
export function ParamCrossfader({ paramId, ...rest }) {
|
|
152
|
+
const param = useParameter(paramId);
|
|
153
|
+
return (_jsx(Crossfader, { value: param.value, onChange: param.set, onBegin: param.begin, onEnd: param.end, ...rest }));
|
|
154
|
+
}
|
|
155
|
+
/** A value-reactive orb: a glowing core emitting echo rings — visual
|
|
156
|
+
feedback for levels, activity, or just presence. */
|
|
157
|
+
export function PulseOrb({ value, size = 120, color = "#C6F135", rings = 4, animate = true, }) {
|
|
158
|
+
const [phase, setPhase] = useState(0);
|
|
159
|
+
useInterval(() => setPhase((p) => (p + 1) % 1000), animate ? 40 : null);
|
|
160
|
+
const level = clamp01(value);
|
|
161
|
+
const core = 14 + level * 12;
|
|
162
|
+
const echoes = [];
|
|
163
|
+
for (let i = 0; i < rings; i++) {
|
|
164
|
+
const t = ((phase * (1 + level * 2) + (i * 100) / rings) % 100) / 100;
|
|
165
|
+
const ringSize = core + t * (size - core - 4);
|
|
166
|
+
const opacity = clamp01((1 - t) * (0.15 + 0.6 * level));
|
|
167
|
+
echoes.push(_jsx(View, { className: "absolute rounded-full border", style: {
|
|
168
|
+
width: ringSize,
|
|
169
|
+
height: ringSize,
|
|
170
|
+
left: (size - ringSize) / 2,
|
|
171
|
+
top: (size - ringSize) / 2,
|
|
172
|
+
borderColor: color,
|
|
173
|
+
opacity,
|
|
174
|
+
} }, i));
|
|
175
|
+
}
|
|
176
|
+
return (_jsxs(View, { className: "relative", style: { width: size, height: size }, children: [echoes, _jsx(View, { className: "absolute rounded-full", style: {
|
|
177
|
+
width: core,
|
|
178
|
+
height: core,
|
|
179
|
+
left: (size - core) / 2,
|
|
180
|
+
top: (size - core) / 2,
|
|
181
|
+
backgroundColor: color,
|
|
182
|
+
shadowColor: color,
|
|
183
|
+
shadowRadius: 6 + level * 14,
|
|
184
|
+
opacity: 0.55 + 0.45 * level,
|
|
185
|
+
} })] }));
|
|
186
|
+
}
|
package/package.json
CHANGED
package/src/components.ts
CHANGED
|
@@ -64,3 +64,21 @@ export type {
|
|
|
64
64
|
} from "./fields";
|
|
65
65
|
export { ProgressBar, Spinner } from "./feedback";
|
|
66
66
|
export type { ProgressBarProps, SpinnerProps } from "./feedback";
|
|
67
|
+
export {
|
|
68
|
+
MacroPad,
|
|
69
|
+
ParamMacroPad,
|
|
70
|
+
HardwareKnob,
|
|
71
|
+
ParamHardwareKnob,
|
|
72
|
+
Crossfader,
|
|
73
|
+
ParamCrossfader,
|
|
74
|
+
PulseOrb,
|
|
75
|
+
} from "./specialty";
|
|
76
|
+
export type {
|
|
77
|
+
MacroPadProps,
|
|
78
|
+
ParamMacroPadProps,
|
|
79
|
+
HardwareKnobProps,
|
|
80
|
+
ParamHardwareKnobProps,
|
|
81
|
+
CrossfaderProps,
|
|
82
|
+
ParamCrossfaderProps,
|
|
83
|
+
PulseOrbProps,
|
|
84
|
+
} from "./specialty";
|
package/src/controls.test.tsx
CHANGED
|
@@ -664,3 +664,112 @@ describe("0.0.10 — fields & feedback", () => {
|
|
|
664
664
|
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "GAIN")).toBe(true);
|
|
665
665
|
});
|
|
666
666
|
});
|
|
667
|
+
|
|
668
|
+
describe("0.0.11 — flagship visuals", () => {
|
|
669
|
+
test("MacroPad: drag maps both axes, double-click recenters, rings render", () => {
|
|
670
|
+
const { MacroPad } = require("./index");
|
|
671
|
+
const seen: Array<[number, number]> = [];
|
|
672
|
+
render(
|
|
673
|
+
<MacroPad x={0.5} y={0.5} size={200} animate={false} rings={6} onChange={(x: number, y: number) => seen.push([x, y])} />,
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const id = nodeWithListener("drag");
|
|
677
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
678
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 50, dy: -50, x: 0, y: 0 } });
|
|
679
|
+
expect(seen.at(-1)?.[0]).toBeCloseTo(0.75);
|
|
680
|
+
expect(seen.at(-1)?.[1]).toBeCloseTo(0.75);
|
|
681
|
+
|
|
682
|
+
dispatch({ kind: "event", nodeId: id, type: "dblclick" });
|
|
683
|
+
expect(seen.at(-1)).toEqual([0.5, 0.5]);
|
|
684
|
+
|
|
685
|
+
// six rings + thumb: ring views carry borderColor + opacity
|
|
686
|
+
const rings = opsNamed("setProps").filter(
|
|
687
|
+
(op: any) => op[2]?.style?.borderColor === "#C6F135" && op[2]?.style?.opacity !== undefined,
|
|
688
|
+
);
|
|
689
|
+
expect(rings.length).toBeGreaterThanOrEqual(6);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("ParamMacroPad opens and closes both gestures", () => {
|
|
693
|
+
const { ParamMacroPad } = require("./index");
|
|
694
|
+
render(<ParamMacroPad paramX="cutoff" paramY="res" animate={false} />);
|
|
695
|
+
|
|
696
|
+
const id = nodeWithListener("drag");
|
|
697
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
698
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 20, dy: 0, x: 0, y: 0 } });
|
|
699
|
+
dispatch({ kind: "event", nodeId: id, type: "dragend", payload: { dx: 20, dy: 0, x: 0, y: 0 } });
|
|
700
|
+
|
|
701
|
+
expect(nativeCalls.filter((c) => c.name === "param:begin").length).toBe(2);
|
|
702
|
+
expect(nativeCalls.filter((c) => c.name === "param:end").length).toBe(2);
|
|
703
|
+
expect(nativeCalls.filter((c) => c.name === "param:set").length).toBe(2);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test("HardwareKnob: pointer notch tracks the angle; DAW gestures work", () => {
|
|
707
|
+
const { HardwareKnob } = require("./index");
|
|
708
|
+
const seen: number[] = [];
|
|
709
|
+
render(<HardwareKnob value={0.5} defaultValue={0.25} onChange={(v: number) => seen.push(v)} />);
|
|
710
|
+
|
|
711
|
+
// value 0.5 -> angle 0 -> notch arc -4..+4
|
|
712
|
+
const notch: any = opsNamed("setProps").find(
|
|
713
|
+
(op: any) => op[2]?.style?.arcValueStart === -4 && op[2]?.style?.arcValueEnd === 4,
|
|
714
|
+
);
|
|
715
|
+
expect(notch).toBeDefined();
|
|
716
|
+
|
|
717
|
+
const id = nodeWithListener("dblclick");
|
|
718
|
+
dispatch({ kind: "event", nodeId: id, type: "dblclick" });
|
|
719
|
+
expect(seen.at(-1)).toBe(0.25);
|
|
720
|
+
|
|
721
|
+
dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 0.1 } });
|
|
722
|
+
expect(seen.at(-1)).toBeCloseTo(0.54);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("Crossfader: horizontal drag with travel compensation, labels render", () => {
|
|
726
|
+
const { Crossfader } = require("./index");
|
|
727
|
+
const seen: number[] = [];
|
|
728
|
+
render(<Crossfader value={0.5} width={220} onChange={(v: number) => seen.push(v)} />);
|
|
729
|
+
|
|
730
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "DRY")).toBe(true);
|
|
731
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "WET")).toBe(true);
|
|
732
|
+
|
|
733
|
+
const id = nodeWithListener("drag");
|
|
734
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
735
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 94, dy: 0, x: 0, y: 0 } }); // travel = 220-26-6 = 188
|
|
736
|
+
expect(seen.at(-1)).toBeCloseTo(1);
|
|
737
|
+
|
|
738
|
+
dispatch({ kind: "event", nodeId: id, type: "dblclick" });
|
|
739
|
+
expect(seen.at(-1)).toBe(0.5);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test("PulseOrb: echo rings advance with the phase", async () => {
|
|
743
|
+
const { PulseOrb } = require("./index");
|
|
744
|
+
render(<PulseOrb value={0.8} size={100} rings={3} />);
|
|
745
|
+
|
|
746
|
+
const sizesAt = () =>
|
|
747
|
+
opsNamed("setProps")
|
|
748
|
+
.filter((op: any) => op[2]?.style?.borderColor === "#C6F135")
|
|
749
|
+
.map((op: any) => op[2].style.width);
|
|
750
|
+
const before = sizesAt().length;
|
|
751
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
752
|
+
expect(sizesAt().length).toBeGreaterThan(before); // new frames landed
|
|
753
|
+
|
|
754
|
+
// the core glows with the level
|
|
755
|
+
const core: any = opsNamed("setProps").find((op: any) => op[2]?.style?.shadowColor === "#C6F135");
|
|
756
|
+
expect(core).toBeDefined();
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("Toggle side labels highlight the active side; Slider barThumb renders a bar", () => {
|
|
760
|
+
const { Toggle: T2, Slider: S2 } = require("./index");
|
|
761
|
+
render(<T2 on={false} offLabel="OFF" onLabel="ON" onChange={() => {}} />);
|
|
762
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "OFF")).toBe(true);
|
|
763
|
+
expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "ON")).toBe(true);
|
|
764
|
+
|
|
765
|
+
unmount();
|
|
766
|
+
batches.length = 0;
|
|
767
|
+
render(<S2 vertical barThumb height={100} value={0.5} onChange={() => {}} />);
|
|
768
|
+
// bar thumb: 18 wide, 5 tall at the halfway point
|
|
769
|
+
const bar: any = opsNamed("setProps").find(
|
|
770
|
+
(op: any) => op[2]?.style?.width === 18 && op[2]?.style?.height === 5,
|
|
771
|
+
);
|
|
772
|
+
expect(bar).toBeDefined();
|
|
773
|
+
expect(bar[2].style.top).toBeCloseTo(0.5 * 95);
|
|
774
|
+
});
|
|
775
|
+
});
|
package/src/controls.tsx
CHANGED
|
@@ -152,6 +152,8 @@ export interface SliderProps {
|
|
|
152
152
|
height?: number;
|
|
153
153
|
/** Vertical fader — drag up for more, fill rises from the bottom. */
|
|
154
154
|
vertical?: boolean;
|
|
155
|
+
/** Flat hardware-style bar thumb instead of the dot. */
|
|
156
|
+
barThumb?: boolean;
|
|
155
157
|
label?: string;
|
|
156
158
|
disabled?: boolean;
|
|
157
159
|
/** Double-click resets to this (DAW convention). */
|
|
@@ -170,6 +172,7 @@ export function Slider({
|
|
|
170
172
|
width = 160,
|
|
171
173
|
height = 160,
|
|
172
174
|
vertical,
|
|
175
|
+
barThumb,
|
|
173
176
|
label,
|
|
174
177
|
disabled,
|
|
175
178
|
defaultValue,
|
|
@@ -223,10 +226,17 @@ export function Slider({
|
|
|
223
226
|
className="absolute w-[4] rounded-full left-[7] bottom-0"
|
|
224
227
|
style={{ height: clamped * height, backgroundColor: valueColor }}
|
|
225
228
|
/>
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
229
|
+
{barThumb ? (
|
|
230
|
+
<View
|
|
231
|
+
className="absolute w-[18] h-[5] rounded-[2] left-0"
|
|
232
|
+
style={{ top: (1 - clamped) * (height - 5), backgroundColor: valueColor }}
|
|
233
|
+
/>
|
|
234
|
+
) : (
|
|
235
|
+
<View
|
|
236
|
+
className="absolute w-[12] h-[12] rounded-full left-[3]"
|
|
237
|
+
style={{ top: (1 - clamped) * (height - 12), backgroundColor: valueColor }}
|
|
238
|
+
/>
|
|
239
|
+
)}
|
|
230
240
|
</View>
|
|
231
241
|
{label !== undefined ? (
|
|
232
242
|
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
|
@@ -251,10 +261,17 @@ export function Slider({
|
|
|
251
261
|
className="absolute h-[4] rounded-full top-[7]"
|
|
252
262
|
style={{ width: clamped * width, backgroundColor: valueColor }}
|
|
253
263
|
/>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
264
|
+
{barThumb ? (
|
|
265
|
+
<View
|
|
266
|
+
className="absolute w-[5] h-[18] rounded-[2] top-0"
|
|
267
|
+
style={{ left: clamped * (width - 5), backgroundColor: valueColor }}
|
|
268
|
+
/>
|
|
269
|
+
) : (
|
|
270
|
+
<View
|
|
271
|
+
className="absolute w-[12] h-[12] rounded-full top-[3]"
|
|
272
|
+
style={{ left: clamped * (width - 12), backgroundColor: valueColor }}
|
|
273
|
+
/>
|
|
274
|
+
)}
|
|
258
275
|
</View>
|
|
259
276
|
{label !== undefined ? (
|
|
260
277
|
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
|
@@ -295,6 +312,9 @@ export function ParamSlider({ paramId, label, ...rest }: ParamSliderProps) {
|
|
|
295
312
|
export interface ToggleProps {
|
|
296
313
|
on: boolean;
|
|
297
314
|
label?: string;
|
|
315
|
+
/** Side captions, hardware style: <Toggle offLabel="OFF" onLabel="ON" />. */
|
|
316
|
+
offLabel?: string;
|
|
317
|
+
onLabel?: string;
|
|
298
318
|
/** Track height; width is 1.8×. Default 22. */
|
|
299
319
|
size?: number;
|
|
300
320
|
disabled?: boolean;
|
|
@@ -308,6 +328,8 @@ export interface ToggleProps {
|
|
|
308
328
|
export function Toggle({
|
|
309
329
|
on,
|
|
310
330
|
label,
|
|
331
|
+
offLabel,
|
|
332
|
+
onLabel,
|
|
311
333
|
size = 22,
|
|
312
334
|
disabled,
|
|
313
335
|
trackColor = "#2A2F27",
|
|
@@ -322,21 +344,39 @@ export function Toggle({
|
|
|
322
344
|
|
|
323
345
|
return (
|
|
324
346
|
<View className="items-center gap-2">
|
|
325
|
-
<View
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
347
|
+
<View className="flex-row items-center gap-2">
|
|
348
|
+
{offLabel !== undefined ? (
|
|
349
|
+
<Text
|
|
350
|
+
className="text-[9] font-bold tracking-widest"
|
|
351
|
+
style={{ color: on ? "#6f6e66" : "#ECF2E8" }}
|
|
352
|
+
>
|
|
353
|
+
{offLabel}
|
|
354
|
+
</Text>
|
|
355
|
+
) : null}
|
|
330
356
|
<View
|
|
331
|
-
className=
|
|
332
|
-
style={{
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
357
|
+
className={`relative rounded-full ${disabled ? "opacity-40" : "cursor-pointer"}`}
|
|
358
|
+
style={{ width: trackWidth, height: size, backgroundColor: on ? onColor : trackColor }}
|
|
359
|
+
onClick={disabled ? undefined : () => onChange(!on)}
|
|
360
|
+
>
|
|
361
|
+
<View
|
|
362
|
+
className="absolute rounded-full"
|
|
363
|
+
style={{
|
|
364
|
+
width: thumb,
|
|
365
|
+
height: thumb,
|
|
366
|
+
top: 3,
|
|
367
|
+
left: 3 + clamp01(t) * travel,
|
|
368
|
+
backgroundColor: thumbColor,
|
|
369
|
+
}}
|
|
370
|
+
/>
|
|
371
|
+
</View>
|
|
372
|
+
{onLabel !== undefined ? (
|
|
373
|
+
<Text
|
|
374
|
+
className="text-[9] font-bold tracking-widest"
|
|
375
|
+
style={{ color: on ? "#ECF2E8" : "#6f6e66" }}
|
|
376
|
+
>
|
|
377
|
+
{onLabel}
|
|
378
|
+
</Text>
|
|
379
|
+
) : null}
|
|
340
380
|
</View>
|
|
341
381
|
{label !== undefined ? (
|
|
342
382
|
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
package/src/index.ts
CHANGED
|
@@ -90,4 +90,22 @@ export type {
|
|
|
90
90
|
} from "./fields";
|
|
91
91
|
export { ProgressBar, Spinner } from "./feedback";
|
|
92
92
|
export type { ProgressBarProps, SpinnerProps } from "./feedback";
|
|
93
|
+
export {
|
|
94
|
+
MacroPad,
|
|
95
|
+
ParamMacroPad,
|
|
96
|
+
HardwareKnob,
|
|
97
|
+
ParamHardwareKnob,
|
|
98
|
+
Crossfader,
|
|
99
|
+
ParamCrossfader,
|
|
100
|
+
PulseOrb,
|
|
101
|
+
} from "./specialty";
|
|
102
|
+
export type {
|
|
103
|
+
MacroPadProps,
|
|
104
|
+
ParamMacroPadProps,
|
|
105
|
+
HardwareKnobProps,
|
|
106
|
+
ParamHardwareKnobProps,
|
|
107
|
+
CrossfaderProps,
|
|
108
|
+
ParamCrossfaderProps,
|
|
109
|
+
PulseOrbProps,
|
|
110
|
+
} from "./specialty";
|
|
93
111
|
export type { DragEventPayload, LayoutRect, WheelEventPayload } from "./primitives";
|
|
@@ -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
|
+
}
|