panelui-native 0.13.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +41 -23
  2. package/lib/module/components/button/index.js +12 -9
  3. package/lib/module/components/button/index.js.map +1 -1
  4. package/lib/module/components/chip/index.js +313 -0
  5. package/lib/module/components/chip/index.js.map +1 -0
  6. package/lib/module/components/direction/index.js +86 -0
  7. package/lib/module/components/direction/index.js.map +1 -0
  8. package/lib/module/components/frame/index.js +41 -39
  9. package/lib/module/components/frame/index.js.map +1 -1
  10. package/lib/module/components/heatmap-chart/index.js +772 -0
  11. package/lib/module/components/heatmap-chart/index.js.map +1 -0
  12. package/lib/module/components/input/index.js +4 -0
  13. package/lib/module/components/input/index.js.map +1 -1
  14. package/lib/module/components/message/index.js +16 -1
  15. package/lib/module/components/message/index.js.map +1 -1
  16. package/lib/module/components/scroll-canvas/index.js +175 -0
  17. package/lib/module/components/scroll-canvas/index.js.map +1 -0
  18. package/lib/module/components/scroll-text/index.js +213 -0
  19. package/lib/module/components/scroll-text/index.js.map +1 -0
  20. package/lib/module/components/select/index.js +20 -24
  21. package/lib/module/components/select/index.js.map +1 -1
  22. package/lib/module/components/slider/index.js +2 -5
  23. package/lib/module/components/slider/index.js.map +1 -1
  24. package/lib/module/components/soundwave/index.js +813 -0
  25. package/lib/module/components/soundwave/index.js.map +1 -0
  26. package/lib/module/components/switch/index.js +15 -17
  27. package/lib/module/components/switch/index.js.map +1 -1
  28. package/lib/module/components/thinking-orb/index.js +741 -0
  29. package/lib/module/components/thinking-orb/index.js.map +1 -0
  30. package/lib/module/components/toggle-button/index.js +12 -3
  31. package/lib/module/components/toggle-button/index.js.map +1 -1
  32. package/lib/module/components/typography/index.js +196 -5
  33. package/lib/module/components/typography/index.js.map +1 -1
  34. package/lib/module/hooks/index.js +1 -0
  35. package/lib/module/hooks/index.js.map +1 -1
  36. package/lib/module/hooks/use-keyboard-avoidance.js +99 -59
  37. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  38. package/lib/module/hooks/use-reveal-progress.js +75 -0
  39. package/lib/module/hooks/use-reveal-progress.js.map +1 -0
  40. package/lib/module/icons/index.js +69 -0
  41. package/lib/module/icons/index.js.map +1 -1
  42. package/lib/module/index.js +9 -1
  43. package/lib/module/index.js.map +1 -1
  44. package/lib/module/native/index.js.map +1 -1
  45. package/lib/module/primitives/keyboard-avoider.js +21 -2
  46. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  47. package/lib/module/primitives/scroll-progress.js +114 -0
  48. package/lib/module/primitives/scroll-progress.js.map +1 -0
  49. package/lib/typescript/src/components/button/index.d.ts +3 -4
  50. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  51. package/lib/typescript/src/components/chip/index.d.ts +304 -0
  52. package/lib/typescript/src/components/chip/index.d.ts.map +1 -0
  53. package/lib/typescript/src/components/direction/index.d.ts +64 -0
  54. package/lib/typescript/src/components/direction/index.d.ts.map +1 -0
  55. package/lib/typescript/src/components/frame/index.d.ts +15 -13
  56. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  57. package/lib/typescript/src/components/heatmap-chart/index.d.ts +260 -0
  58. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -0
  59. package/lib/typescript/src/components/input/index.d.ts +19 -4
  60. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  61. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  62. package/lib/typescript/src/components/scroll-canvas/index.d.ts +66 -0
  63. package/lib/typescript/src/components/scroll-canvas/index.d.ts.map +1 -0
  64. package/lib/typescript/src/components/scroll-text/index.d.ts +46 -0
  65. package/lib/typescript/src/components/scroll-text/index.d.ts.map +1 -0
  66. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  68. package/lib/typescript/src/components/soundwave/index.d.ts +91 -0
  69. package/lib/typescript/src/components/soundwave/index.d.ts.map +1 -0
  70. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  71. package/lib/typescript/src/components/thinking-orb/index.d.ts +28 -0
  72. package/lib/typescript/src/components/thinking-orb/index.d.ts.map +1 -0
  73. package/lib/typescript/src/components/toggle-button/index.d.ts +12 -0
  74. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/typography/index.d.ts +154 -1
  76. package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
  77. package/lib/typescript/src/hooks/index.d.ts +2 -1
  78. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  79. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +16 -3
  80. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  81. package/lib/typescript/src/hooks/use-reveal-progress.d.ts +56 -0
  82. package/lib/typescript/src/hooks/use-reveal-progress.d.ts.map +1 -0
  83. package/lib/typescript/src/icons/index.d.ts +6 -0
  84. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  85. package/lib/typescript/src/index.d.ts +9 -1
  86. package/lib/typescript/src/index.d.ts.map +1 -1
  87. package/lib/typescript/src/native/index.d.ts +15 -7
  88. package/lib/typescript/src/native/index.d.ts.map +1 -1
  89. package/lib/typescript/src/primitives/keyboard-avoider.d.ts +30 -3
  90. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  91. package/lib/typescript/src/primitives/scroll-progress.d.ts +61 -0
  92. package/lib/typescript/src/primitives/scroll-progress.d.ts.map +1 -0
  93. package/package.json +1 -1
  94. package/src/components/button/index.tsx +13 -11
  95. package/src/components/direction/index.tsx +90 -0
  96. package/src/components/frame/index.tsx +39 -50
  97. package/src/components/heatmap-chart/index.tsx +1024 -0
  98. package/src/components/input/index.tsx +23 -4
  99. package/src/components/message/index.tsx +17 -1
  100. package/src/components/scroll-canvas/index.tsx +220 -0
  101. package/src/components/scroll-text/index.tsx +277 -0
  102. package/src/components/select/index.tsx +4 -9
  103. package/src/components/slider/index.tsx +4 -7
  104. package/src/components/soundwave/index.tsx +1029 -0
  105. package/src/components/switch/index.tsx +5 -8
  106. package/src/components/thinking-orb/index.tsx +792 -0
  107. package/src/components/typography/index.tsx +231 -10
  108. package/src/hooks/index.ts +6 -0
  109. package/src/hooks/use-keyboard-avoidance.ts +111 -65
  110. package/src/hooks/use-reveal-progress.ts +106 -0
  111. package/src/icons/index.tsx +36 -0
  112. package/src/index.ts +53 -0
  113. package/src/native/index.ts +15 -7
  114. package/src/primitives/keyboard-avoider.tsx +41 -4
  115. package/src/primitives/scroll-progress.tsx +167 -0
@@ -0,0 +1,813 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Soundwave — what a voice looks like while an app is listening to it.
5
+ *
6
+ * ```tsx
7
+ * <Soundwave variant="pills" state="listening" level={level} />
8
+ * ```
9
+ *
10
+ * Four looks, because a voice screen needs different ones in different places:
11
+ * `pills` for the few big capsules over a microphone button, `bars` for a
12
+ * metering strip in a transcript, `line` for a travelling wave while the
13
+ * assistant talks back, and `ambient` for a glow that takes the whole screen.
14
+ *
15
+ * ## It draws a level; it does not record one
16
+ *
17
+ * Nothing here touches the microphone. The app owns the recorder — the
18
+ * permission prompt, the session, the platform quirks — and hands over a number
19
+ * between 0 and 1, which keeps this component free of an audio dependency and
20
+ * usable against a real meter, a synthesised one, or a remote peer's.
21
+ *
22
+ * With no `level` at all it animates its own plausible motion for the current
23
+ * `state`, so a screen can be built and reviewed before any audio exists.
24
+ *
25
+ * ## Levels arrive faster than React should re-render
26
+ *
27
+ * A recorder reports every 30–60ms. Setting that in state is dozens of renders
28
+ * a second for a number that only moves pixels, so `level` also accepts a
29
+ * `SharedValue`: write metering straight into it and nothing above this
30
+ * component ever re-renders. A plain number works too and is smoothed the same
31
+ * way — it is the right choice when the level comes from something slow, like a
32
+ * server-side speaking flag.
33
+ *
34
+ * Either way the value is smoothed with a fast attack and a slow release, which
35
+ * is what makes a meter read as a meter: it snaps up on a syllable and falls
36
+ * back gently, instead of chattering around every sample.
37
+ *
38
+ * ## How each one is drawn
39
+ *
40
+ * `bars` and `line` are a *single* animated SVG path — one vertical segment per
41
+ * bar with a round cap, or one polyline — so forty bars cost one animated prop
42
+ * a frame rather than forty animated views, and the capsule ends come free from
43
+ * the stroke cap. `pills` is a handful of views, where that machinery would be
44
+ * more expensive than the thing it saves. Everything runs in a worklet on the
45
+ * UI thread; React renders on a resize and otherwise not at all.
46
+ */
47
+ import { useEffect, useId, useState } from 'react';
48
+ import { StyleSheet, View } from 'react-native';
49
+ import { LinearGradient } from 'expo-linear-gradient';
50
+ import Animated, { useAnimatedProps, useAnimatedStyle, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
51
+ import Svg, { Defs, LinearGradient as SvgGradient, Path, Stop } from 'react-native-svg';
52
+ import { useCSSVariable } from 'uniwind';
53
+ import { useIconColor } from "../../icons/index.js";
54
+ import { cn } from "../../utils/cn.js";
55
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
56
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
57
+ /** What each state is doing, for anyone who cannot see it. */
58
+ const STATE_LABEL = {
59
+ idle: 'Idle',
60
+ listening: 'Listening',
61
+ thinking: 'Thinking',
62
+ speaking: 'Speaking'
63
+ };
64
+
65
+ /**
66
+ * How fast the smoothed level rises and falls, per second.
67
+ *
68
+ * Wildly asymmetric on purpose. A meter that rises and falls at the same rate
69
+ * either lags the syllable that caused it or flickers on every sample; snapping
70
+ * up and easing down tracks speech the way an ear expects it to.
71
+ */
72
+ const ATTACK = 16;
73
+ const RELEASE = 5;
74
+
75
+ /** Never quite silent: a wave flat at zero reads as broken rather than quiet. */
76
+ const FLOOR = 0.04;
77
+
78
+ /* -------------------------------------------------------------------------- */
79
+ /* Worklet maths */
80
+ /* -------------------------------------------------------------------------- */
81
+
82
+ function clamp01(value) {
83
+ 'worklet';
84
+
85
+ return value < 0 ? 0 : value > 1 ? 1 : value;
86
+ }
87
+
88
+ /** Deterministic hash in `[0, 1)`. Stable across frames and across mounts. */
89
+ function hashD(a, b) {
90
+ 'worklet';
91
+
92
+ const h = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453;
93
+ return h - Math.floor(h);
94
+ }
95
+
96
+ /** One decimal place. Path strings are rebuilt every frame; every digit costs. */
97
+ function q(value) {
98
+ 'worklet';
99
+
100
+ return Math.round(value * 10) / 10;
101
+ }
102
+
103
+ /**
104
+ * The motion a state runs on its own, with no level supplied.
105
+ *
106
+ * Layered at unrelated tempi so it never repeats on a beat — a single sine
107
+ * reads as a pulse, and a pulse reads as a progress indicator rather than as a
108
+ * voice.
109
+ */
110
+ function idleEnergy(state, t) {
111
+ 'worklet';
112
+
113
+ if (state === 'listening') {
114
+ return clamp01(0.34 + 0.22 * Math.sin(t * 2.3) + 0.16 * Math.sin(t * 3.9 + 1.3) + 0.1 * Math.sin(t * 7.1 + 0.6));
115
+ }
116
+ if (state === 'speaking') {
117
+ return clamp01(0.46 + 0.27 * Math.sin(t * 3.1) + 0.18 * Math.sin(t * 5.7 + 0.8) + 0.09 * Math.sin(t * 9.3));
118
+ }
119
+ if (state === 'thinking') {
120
+ return clamp01(0.18 + 0.1 * Math.sin(t * 1.6) + 0.05 * Math.sin(t * 2.9 + 2.1));
121
+ }
122
+ return clamp01(0.07 + 0.05 * Math.sin(t * 1.1));
123
+ }
124
+
125
+ /* -------------------------------------------------------------------------- */
126
+ /* The clock every variant shares */
127
+ /* -------------------------------------------------------------------------- */
128
+
129
+ const isShared = value => typeof value === 'object' && value !== null && 'value' in value;
130
+
131
+ /**
132
+ * One shared value to read, whether the caller passed a number or their own.
133
+ *
134
+ * A plain number is copied in; a `SharedValue` is handed straight back, which
135
+ * is the whole reason for accepting one — metering written into it never
136
+ * re-renders anything.
137
+ */
138
+ function useNumberSource(value) {
139
+ const own = useSharedValue(0);
140
+ const external = isShared(value) ? value : null;
141
+ useEffect(() => {
142
+ if (typeof value === 'number') own.value = value;
143
+ }, [value, own]);
144
+ return external ?? own;
145
+ }
146
+
147
+ /** How often `scrolling` mode takes a sample, in seconds. */
148
+ const SAMPLE_INTERVAL = 1 / 24;
149
+ function useEngine({
150
+ level,
151
+ state,
152
+ sensitivity,
153
+ speed,
154
+ paused,
155
+ history
156
+ }) {
157
+ const reducedMotion = useReducedMotion();
158
+ const clock = useSharedValue(0);
159
+ const energy = useSharedValue(0);
160
+ const samples = useSharedValue([]);
161
+ const nextSample = useSharedValue(0);
162
+ const source = useNumberSource(level);
163
+ const driven = level !== undefined;
164
+ const running = !paused && !reducedMotion;
165
+ const frame = useFrameCallback(info => {
166
+ 'worklet';
167
+
168
+ // Elapsed time is accumulated rather than read off the total, so `speed`
169
+ // can change mid-animation without the wave jumping to wherever the new
170
+ // rate would have put it. A dropped frame is clamped rather than honoured —
171
+ // a 300ms hitch played back at full rate is a lurch.
172
+ const delta = Math.min(info.timeSincePreviousFrame ?? 16, 48) / 1000;
173
+ clock.value += delta * speed;
174
+ const target = driven ? clamp01(source.value * sensitivity) : idleEnergy(state, clock.value);
175
+ const rate = target > energy.value ? ATTACK : RELEASE;
176
+ energy.value += (target - energy.value) * Math.min(1, delta * rate);
177
+ if (history > 0 && clock.value >= nextSample.value) {
178
+ nextSample.value = clock.value + SAMPLE_INTERVAL;
179
+ // A little per-sample texture, so a held note is a band of varying bars
180
+ // rather than a solid block — real speech never gives two equal samples.
181
+ const jitter = 0.82 + 0.36 * hashD(clock.value, 3.3);
182
+ const next = samples.value.slice(samples.value.length >= history ? 1 : 0);
183
+ next.push(clamp01(energy.value * jitter));
184
+ samples.value = next;
185
+ }
186
+ }, false);
187
+ const {
188
+ setActive
189
+ } = frame;
190
+ useEffect(() => {
191
+ setActive(running);
192
+ return () => setActive(false);
193
+ }, [running, setActive]);
194
+
195
+ // Stopped is not empty: reduced motion and `paused` both get a representative
196
+ // frame rather than a flat line, which is the difference between "not
197
+ // animating" and "broken".
198
+ useEffect(() => {
199
+ if (running) return;
200
+ const still = driven ? clamp01(source.value * sensitivity) : 0.42;
201
+ energy.value = still;
202
+ if (history > 0) {
203
+ samples.value = Array.from({
204
+ length: history
205
+ }, (_unused, index) => clamp01(still * (0.45 + 0.55 * Math.abs(Math.sin(index * 0.7)))));
206
+ }
207
+ }, [running, driven, sensitivity, history, source, energy, samples]);
208
+ return {
209
+ clock,
210
+ energy,
211
+ samples
212
+ };
213
+ }
214
+
215
+ /* -------------------------------------------------------------------------- */
216
+ /* pills */
217
+ /* -------------------------------------------------------------------------- */
218
+
219
+ /**
220
+ * One capsule.
221
+ *
222
+ * A component rather than a loop of `useAnimatedStyle` in the parent, because
223
+ * the count is a prop — hooks in a loop is a rule waiting to be broken by
224
+ * whoever changes the default.
225
+ */
226
+ function Pill({
227
+ index,
228
+ count,
229
+ engine,
230
+ width,
231
+ minHeight,
232
+ maxHeight,
233
+ color
234
+ }) {
235
+ const style = useAnimatedStyle(() => {
236
+ /*
237
+ * Each capsule runs at its own tempo and phase, hashed off its index. Give
238
+ * them one tempo and they rise and fall as a block, which reads as a
239
+ * loading bar; detune them and the group reads as something responding to a
240
+ * voice, even though they all follow the same level.
241
+ */
242
+ const rate = 2.2 + hashD(index, 1.7) * 2.6;
243
+ const phase = hashD(index, 5.1) * Math.PI * 2;
244
+ const wobble = 0.55 + 0.45 * Math.sin(engine.clock.value * rate + phase);
245
+
246
+ // The middle capsules lead. A voice meter with a flat profile looks like a
247
+ // level indicator; a slight hump looks like a mouth.
248
+ const centre = count > 1 ? 1 - Math.abs(index / (count - 1) * 2 - 1) : 1;
249
+ const shape = 0.68 + 0.32 * centre;
250
+ const value = clamp01(Math.max(FLOOR, engine.energy.value) * wobble * shape);
251
+ return {
252
+ height: minHeight + (maxHeight - minHeight) * value
253
+ };
254
+ });
255
+ return /*#__PURE__*/_jsx(Animated.View, {
256
+ style: [{
257
+ width,
258
+ borderRadius: width / 2,
259
+ backgroundColor: color
260
+ }, style]
261
+ });
262
+ }
263
+ function PillsWave({
264
+ engine,
265
+ count,
266
+ barWidth,
267
+ barGap,
268
+ height,
269
+ color
270
+ }) {
271
+ // Never shorter than a circle: a capsule squashed past its own width stops
272
+ // being a capsule.
273
+ const minHeight = barWidth * 1.35;
274
+ return /*#__PURE__*/_jsx(View, {
275
+ style: {
276
+ height,
277
+ columnGap: barGap
278
+ },
279
+ className: "flex-row items-center justify-center",
280
+ children: Array.from({
281
+ length: count
282
+ }, (_unused, index) => /*#__PURE__*/_jsx(Pill, {
283
+ index: index,
284
+ count: count,
285
+ engine: engine,
286
+ width: barWidth,
287
+ minHeight: minHeight,
288
+ maxHeight: height,
289
+ color: color
290
+ }, index))
291
+ });
292
+ }
293
+
294
+ /* -------------------------------------------------------------------------- */
295
+ /* bars */
296
+ /* -------------------------------------------------------------------------- */
297
+
298
+ /**
299
+ * One bar's height, 0–1, from whichever source is driving this wave.
300
+ *
301
+ * Pulled out of the drawing loop because two paths walk the same bars — the
302
+ * played part of a recording and the rest of it — and a bar has to come out
303
+ * identical in both or the split shows as a step.
304
+ */
305
+ function barValue(i, count, scrolling, supplied, history, clock, energy) {
306
+ 'worklet';
307
+
308
+ if (scrolling) {
309
+ // The buffer fills from empty, so the newest sample sits at the trailing
310
+ // edge from the first frame rather than the wave sliding in from nowhere.
311
+ const offset = i - (count - history.length);
312
+ return offset >= 0 ? history[offset] ?? 0 : 0;
313
+ }
314
+ if (supplied.length) {
315
+ // Real bands, resampled to the bar count — an analysis rarely hands back
316
+ // exactly as many numbers as there are bars, and a recorded waveform never
317
+ // does.
318
+ return supplied[Math.floor(i / count * supplied.length)] ?? 0;
319
+ }
320
+ const rate = 2.1 + hashD(i, 1.3) * 2.9;
321
+ const phase = hashD(i, 4.7) * Math.PI * 2;
322
+ const wobble = 0.5 + 0.5 * Math.sin(clock * rate + phase);
323
+ const centre = count > 1 ? Math.sin(Math.PI * (i + 0.5) / count) : 1;
324
+ return energy * wobble * (0.45 + 0.55 * centre);
325
+ }
326
+
327
+ /** Ink left on the part of a recording that has not played yet. */
328
+ const UNPLAYED_OPACITY = 0.3;
329
+
330
+ /**
331
+ * The stroke a wave is painted with, when it is not one flat colour.
332
+ *
333
+ * One definition covers both jobs, because they are the same object: a run of
334
+ * colour stops across the width, optionally pinned to transparent at each end.
335
+ * Doing the edge fade this way rather than as an overlay in the background
336
+ * colour is what lets a wave sit on a card, a bubble or a photograph — an
337
+ * overlay only disappears against the one surface it was told about.
338
+ */
339
+ function WaveGradient({
340
+ id,
341
+ colors,
342
+ fade,
343
+ edge
344
+ }) {
345
+ const stops = colors.length ? colors : ['#000000'];
346
+ const span = fade ? 1 - edge * 2 : 1;
347
+ const from = fade ? edge : 0;
348
+ const nodes = [];
349
+ if (fade) nodes.push(/*#__PURE__*/_jsx(Stop, {
350
+ offset: "0",
351
+ stopColor: stops[0],
352
+ stopOpacity: "0"
353
+ }, "fade-start"));
354
+ stops.forEach((stop, index) => {
355
+ const at = stops.length > 1 ? from + index / (stops.length - 1) * span : from;
356
+ nodes.push(/*#__PURE__*/_jsx(Stop, {
357
+ offset: String(at),
358
+ stopColor: stop,
359
+ stopOpacity: "1"
360
+ }, index));
361
+ });
362
+ if (fade) {
363
+ nodes.push(/*#__PURE__*/_jsx(Stop, {
364
+ offset: "1",
365
+ stopColor: stops[stops.length - 1],
366
+ stopOpacity: "0"
367
+ }, "fade-end"));
368
+ }
369
+ return /*#__PURE__*/_jsx(Defs, {
370
+ children: /*#__PURE__*/_jsx(SvgGradient, {
371
+ id: id,
372
+ x1: "0",
373
+ y1: "0",
374
+ x2: "1",
375
+ y2: "0",
376
+ children: nodes
377
+ })
378
+ });
379
+ }
380
+ function BarsWave({
381
+ engine,
382
+ bands,
383
+ progress,
384
+ hasProgress,
385
+ count,
386
+ barWidth,
387
+ height,
388
+ width,
389
+ centered,
390
+ scrolling,
391
+ stroke,
392
+ trackStroke,
393
+ defs
394
+ }) {
395
+ /*
396
+ * Two paths, split at the playhead: the bars behind it keep full ink, the
397
+ * ones ahead are dimmed. It is two animated props a frame rather than one,
398
+ * and still nothing per bar — which is the point of drawing bars as a stroked
399
+ * path in the first place.
400
+ *
401
+ * With no `progress` the first path takes everything and the second is empty,
402
+ * so a live meter and a voice note run the same code.
403
+ */
404
+ const drawSegment = played => () => {
405
+ 'worklet';
406
+
407
+ const step = count > 1 ? (width - barWidth) / (count - 1) : 0;
408
+ const span = height - barWidth;
409
+ const supplied = bands.value;
410
+ const history = engine.samples.value;
411
+ const head = hasProgress ? clamp01(progress.value) * count : count;
412
+ let d = '';
413
+ for (let i = 0; i < count; i++) {
414
+ // The playhead cuts between bars, not through one: a bar is either
415
+ // played or it is not, which is what makes the fill land on a beat.
416
+ const isPlayed = i + 0.5 <= head;
417
+ if (isPlayed !== played) continue;
418
+ const value = barValue(i, count, scrolling, supplied, history, engine.clock.value, engine.energy.value);
419
+ const length = span * clamp01(Math.max(FLOOR, value));
420
+ const x = q(barWidth / 2 + i * step);
421
+ if (centered) {
422
+ const half = length / 2;
423
+ d += `M${x} ${q(height / 2 - half)}L${x} ${q(height / 2 + half)}`;
424
+ } else {
425
+ d += `M${x} ${q(height - barWidth / 2)}L${x} ${q(height - barWidth / 2 - length)}`;
426
+ }
427
+ }
428
+ return {
429
+ d
430
+ };
431
+ };
432
+ const playedProps = useAnimatedProps(drawSegment(true));
433
+ const restProps = useAnimatedProps(drawSegment(false));
434
+ return /*#__PURE__*/_jsxs(Svg, {
435
+ width: width,
436
+ height: height,
437
+ children: [defs, /*#__PURE__*/_jsx(AnimatedPath, {
438
+ animatedProps: restProps,
439
+ stroke: trackStroke ?? stroke
440
+ // A track colour of its own is drawn as given; without one, the unplayed
441
+ // part is the same ink turned down.
442
+ ,
443
+ strokeOpacity: trackStroke ? 1 : hasProgress ? UNPLAYED_OPACITY : 1,
444
+ strokeWidth: barWidth,
445
+ strokeLinecap: "round",
446
+ fill: "none"
447
+ }), /*#__PURE__*/_jsx(AnimatedPath, {
448
+ animatedProps: playedProps,
449
+ stroke: stroke,
450
+ strokeWidth: barWidth,
451
+ strokeLinecap: "round",
452
+ fill: "none"
453
+ })]
454
+ });
455
+ }
456
+
457
+ /* -------------------------------------------------------------------------- */
458
+ /* line */
459
+ /* -------------------------------------------------------------------------- */
460
+
461
+ /** Points along the wave. Enough to read as a curve, few enough to rebuild. */
462
+ const LINE_POINTS = 56;
463
+ function LineWave({
464
+ engine,
465
+ width,
466
+ height,
467
+ strokeWidth,
468
+ stroke,
469
+ defs
470
+ }) {
471
+ const animatedProps = useAnimatedProps(() => {
472
+ const mid = height / 2;
473
+ const amplitude = (height / 2 - strokeWidth) * clamp01(Math.max(FLOOR, engine.energy.value));
474
+ const t = engine.clock.value;
475
+ let d = '';
476
+ for (let i = 0; i <= LINE_POINTS; i++) {
477
+ const f = i / LINE_POINTS;
478
+ const x = f * width;
479
+ /*
480
+ * Three waves at unrelated wavelengths, tapered to nothing at both ends.
481
+ * The taper is what makes it a wave and not a rope: the line leaves and
482
+ * meets the edges flat, so there is no hard stop where it is cut off.
483
+ */
484
+ const taper = Math.sin(Math.PI * f);
485
+ const y = mid - amplitude * taper * (0.62 * Math.sin(f * 12.6 - t * 3.1) + 0.26 * Math.sin(f * 21.4 - t * 4.7 + 1.1) + 0.12 * Math.sin(f * 33.2 - t * 2.3));
486
+ d += `${i === 0 ? 'M' : 'L'}${q(x)} ${q(y)}`;
487
+ }
488
+ return {
489
+ d
490
+ };
491
+ });
492
+ return /*#__PURE__*/_jsxs(Svg, {
493
+ width: width,
494
+ height: height,
495
+ children: [defs, /*#__PURE__*/_jsx(AnimatedPath, {
496
+ animatedProps: animatedProps,
497
+ stroke: stroke,
498
+ strokeWidth: strokeWidth,
499
+ strokeLinecap: "round",
500
+ strokeLinejoin: "round",
501
+ fill: "none"
502
+ })]
503
+ });
504
+ }
505
+
506
+ /* -------------------------------------------------------------------------- */
507
+ /* ambient */
508
+ /* -------------------------------------------------------------------------- */
509
+
510
+ /** A theme token name — `--color-info` — rather than a literal colour. */
511
+ const isToken = value => typeof value === 'string' && value.startsWith('--');
512
+
513
+ /** `useCSSVariable` can hand back a non-string when a variable is unset. */
514
+ const asString = value => typeof value === 'string' && value.length > 0 ? value : undefined;
515
+
516
+ /** Turns any resolved colour into the same colour at a given alpha. */
517
+ function withAlpha(color, alpha) {
518
+ if (color.startsWith('#')) {
519
+ const hex = color.slice(1);
520
+ const full = hex.length === 3 ? hex.split('').map(c => c + c).join('') : hex.slice(0, 6);
521
+ const r = parseInt(full.slice(0, 2), 16);
522
+ const g = parseInt(full.slice(2, 4), 16);
523
+ const b = parseInt(full.slice(4, 6), 16);
524
+ if (Number.isNaN(r + g + b)) return color;
525
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
526
+ }
527
+ const channels = color.match(/rgba?\(([^)]+)\)/)?.[1];
528
+ if (channels) {
529
+ const [r, g, b] = channels.split(',').map(part => part.trim());
530
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
531
+ }
532
+ return color;
533
+ }
534
+
535
+ /**
536
+ * The glow that fills a screen: a bloom rising off the bottom edge and a rim of
537
+ * light around the rest.
538
+ *
539
+ * Both breathe on the level rather than only fading in and out — a glow that
540
+ * changes opacity alone reads as a screen dimming, where one that also grows
541
+ * reads as something in the room getting louder.
542
+ */
543
+ function AmbientWave({
544
+ engine,
545
+ color,
546
+ bloomColors,
547
+ radius
548
+ }) {
549
+ const bloom = useAnimatedStyle(() => {
550
+ const e = clamp01(Math.max(FLOOR, engine.energy.value));
551
+ const breath = 0.94 + 0.06 * Math.sin(engine.clock.value * 1.7);
552
+ return {
553
+ opacity: 0.25 + 0.75 * e,
554
+ transform: [{
555
+ scaleY: (0.72 + 0.4 * e) * breath
556
+ }]
557
+ };
558
+ });
559
+ const rim = useAnimatedStyle(() => {
560
+ const e = clamp01(Math.max(FLOOR, engine.energy.value));
561
+ return {
562
+ opacity: 0.12 + 0.5 * e
563
+ };
564
+ });
565
+
566
+ /*
567
+ * The bloom fades in from nothing at the top whatever it is made of, so a
568
+ * supplied palette is ramped rather than used flat — a gradient that starts
569
+ * at full strength has a visible edge across the middle of the screen.
570
+ */
571
+ const ramp = bloomColors?.length ? [withAlpha(bloomColors[0], 0), ...bloomColors.map((stop, index) => withAlpha(stop, 0.16 + 0.34 * index / Math.max(1, bloomColors.length - 1)))] : [withAlpha(color, 0), withAlpha(color, 0.16), withAlpha(color, 0.5)];
572
+ return /*#__PURE__*/_jsxs(View, {
573
+ pointerEvents: "none",
574
+ style: [StyleSheet.absoluteFill, {
575
+ borderRadius: radius,
576
+ overflow: 'hidden'
577
+ }],
578
+ children: [/*#__PURE__*/_jsx(Animated.View, {
579
+ style: [StyleSheet.absoluteFill, rim],
580
+ children: [0, 1, 2].map(ring => /*#__PURE__*/_jsx(View, {
581
+ style: [StyleSheet.absoluteFill, {
582
+ margin: ring * 3,
583
+ borderRadius: Math.max(0, radius - ring * 3),
584
+ borderWidth: 3,
585
+ borderColor: withAlpha(color, 0.4 - ring * 0.13)
586
+ }]
587
+ }, ring))
588
+ }), /*#__PURE__*/_jsx(Animated.View, {
589
+ style: [{
590
+ position: 'absolute',
591
+ left: 0,
592
+ right: 0,
593
+ bottom: 0,
594
+ height: '55%'
595
+ },
596
+ // Anchored to the bottom so growing pushes the bloom up the screen
597
+ // rather than pulling it off the edge.
598
+ {
599
+ transformOrigin: 'bottom'
600
+ }, bloom],
601
+ children: /*#__PURE__*/_jsx(LinearGradient, {
602
+ colors: ramp,
603
+ start: {
604
+ x: 0.5,
605
+ y: 0
606
+ },
607
+ end: {
608
+ x: 0.5,
609
+ y: 1
610
+ },
611
+ style: StyleSheet.absoluteFill
612
+ })
613
+ })]
614
+ });
615
+ }
616
+
617
+ /* -------------------------------------------------------------------------- */
618
+ /* Component */
619
+ /* -------------------------------------------------------------------------- */
620
+
621
+ /** Per-variant geometry, so a bare `<Soundwave variant="bars" />` looks right. */
622
+ const DEFAULTS = {
623
+ pills: {
624
+ bars: 4,
625
+ barWidth: 28,
626
+ barGap: 10,
627
+ height: 96
628
+ },
629
+ bars: {
630
+ bars: 40,
631
+ barWidth: 3,
632
+ barGap: 3,
633
+ height: 56
634
+ },
635
+ line: {
636
+ bars: 0,
637
+ barWidth: 3,
638
+ barGap: 0,
639
+ height: 72
640
+ },
641
+ ambient: {
642
+ bars: 0,
643
+ barWidth: 0,
644
+ barGap: 0,
645
+ height: 0
646
+ }
647
+ };
648
+ export function Soundwave({
649
+ className,
650
+ variant = 'pills',
651
+ state = 'listening',
652
+ level,
653
+ levels,
654
+ progress,
655
+ bars,
656
+ barWidth,
657
+ barGap,
658
+ height,
659
+ mode = 'static',
660
+ centered = true,
661
+ fadeEdges,
662
+ sensitivity = 1,
663
+ speed = 1,
664
+ color,
665
+ gradient,
666
+ trackColor,
667
+ paused = false,
668
+ radius = 44,
669
+ accessibilityLabel,
670
+ style,
671
+ ...props
672
+ }) {
673
+ const defaults = DEFAULTS[variant];
674
+ const count = bars ?? defaults.bars;
675
+ const stroke = barWidth ?? defaults.barWidth;
676
+ const gap = barGap ?? defaults.barGap;
677
+ const box = height ?? defaults.height;
678
+ const scrolling = variant === 'bars' && mode === 'scrolling';
679
+ const fade = fadeEdges ?? (variant === 'line' || scrolling);
680
+ const foreground = useCSSVariable('--color-foreground');
681
+ const info = useCSSVariable('--color-info');
682
+ /*
683
+ * A token name is resolved here rather than by the caller, so `color` and
684
+ * `trackColor` can be written the way the rest of the library is themed —
685
+ * `--color-info` instead of a hex someone has to keep in step with the theme.
686
+ */
687
+ const colorToken = useCSSVariable(isToken(color) ? color : '--color-foreground');
688
+ const trackToken = useCSSVariable(isToken(trackColor) ? trackColor : '--color-foreground');
689
+ const themed = variant === 'ambient' ? info : foreground;
690
+ /*
691
+ * A wave inside a coloured surface has to be drawn in that surface's
692
+ * foreground, not the page's: a sent chat bubble is painted in the primary
693
+ * colour, and in most themes the primary colour *is* the text colour — so a
694
+ * wave that resolved `--color-foreground` for itself would be invisible on
695
+ * the one screen it is most likely to appear on. Surfaces already publish
696
+ * their readable foreground for icons; a wave is ink for the same reason.
697
+ *
698
+ * `ambient` opts out: it is a glow behind a whole screen, not ink on a
699
+ * surface, and it wants its own colour.
700
+ */
701
+ const inherited = useIconColor();
702
+ const surface = variant === 'ambient' ? undefined : inherited;
703
+ const explicit = isToken(color) ? asString(colorToken) : color;
704
+ const ink = explicit ?? surface ?? asString(themed) ?? '#0a0a0a';
705
+ const track = isToken(trackColor) ? asString(trackToken) : trackColor;
706
+
707
+ /*
708
+ * A recording drawn from `levels` has no motion in it: every bar comes from
709
+ * the stored shape, so the clock advances a frame nobody reads. A transcript
710
+ * can hold twenty voice notes, and twenty idle frame callbacks is twenty too
711
+ * many — so a supplied waveform stops the engine, and `progress` still
712
+ * redraws it, because that is a shared value of its own.
713
+ */
714
+ const stillWaveform = variant === 'bars' && !scrolling && (levels?.length ?? 0) > 0;
715
+ const engine = useEngine({
716
+ level,
717
+ state,
718
+ sensitivity,
719
+ speed,
720
+ paused: paused || stillWaveform,
721
+ history: scrolling ? count : 0
722
+ });
723
+
724
+ // Supplied bands are copied into a shared value so the drawing worklet has a
725
+ // single place to read from, whichever way the level arrived.
726
+ const bands = useSharedValue([]);
727
+ useEffect(() => {
728
+ bands.value = levels ?? [];
729
+ }, [levels, bands]);
730
+ const playhead = useNumberSource(progress);
731
+
732
+ // `bars` and `line` are drawn to the width they are given, which is only
733
+ // known after layout — a metering strip is nearly always as wide as its row.
734
+ const [measured, setMeasured] = useState(0);
735
+ const onLayout = event => {
736
+ const next = Math.round(event.nativeEvent.layout.width);
737
+ if (next !== measured) setMeasured(next);
738
+ };
739
+
740
+ // Two Defs in one tree cannot share an id, and a screen can hold more than
741
+ // one wave.
742
+ const gradientId = `panelui-soundwave-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
743
+
744
+ /*
745
+ * A gradient and an edge fade are the same object — colour stops across the
746
+ * width — so one definition serves both, and a wave that needs neither is
747
+ * painted with a flat colour and no `Defs` at all.
748
+ */
749
+ const ramp = gradient?.length ? gradient : null;
750
+ const needsDefs = fade || ramp !== null && ramp.length > 1;
751
+ const paint = needsDefs ? `url(#${gradientId})` : ink;
752
+ const defs = needsDefs ? /*#__PURE__*/_jsx(WaveGradient, {
753
+ id: gradientId,
754
+ colors: ramp ?? [ink],
755
+ fade: fade,
756
+ edge: variant === 'line' ? 0.18 : 0.12
757
+ }) : null;
758
+ let content = null;
759
+ if (variant === 'pills') {
760
+ content = /*#__PURE__*/_jsx(PillsWave, {
761
+ engine: engine,
762
+ count: count,
763
+ barWidth: stroke,
764
+ barGap: gap,
765
+ height: box,
766
+ color: ink
767
+ });
768
+ } else if (variant === 'ambient') {
769
+ content = /*#__PURE__*/_jsx(AmbientWave, {
770
+ engine: engine,
771
+ color: ink,
772
+ bloomColors: ramp,
773
+ radius: radius
774
+ });
775
+ } else if (measured > 0) {
776
+ content = variant === 'bars' ? /*#__PURE__*/_jsx(BarsWave, {
777
+ engine: engine,
778
+ bands: bands,
779
+ progress: playhead,
780
+ hasProgress: progress !== undefined,
781
+ count: count,
782
+ barWidth: stroke,
783
+ height: box,
784
+ width: measured,
785
+ centered: centered,
786
+ scrolling: scrolling,
787
+ stroke: paint,
788
+ trackStroke: track ?? null,
789
+ defs: defs
790
+ }) : /*#__PURE__*/_jsx(LineWave, {
791
+ engine: engine,
792
+ width: measured,
793
+ height: box,
794
+ strokeWidth: stroke,
795
+ stroke: paint,
796
+ defs: defs
797
+ });
798
+ }
799
+ return /*#__PURE__*/_jsx(View, {
800
+ ...props,
801
+ onLayout: variant === 'bars' || variant === 'line' ? onLayout : undefined,
802
+ pointerEvents: variant === 'ambient' ? 'none' : props.pointerEvents,
803
+ accessibilityRole: "image",
804
+ accessibilityLabel: accessibilityLabel ?? STATE_LABEL[state],
805
+ className: cn(variant === 'ambient' ? 'absolute inset-0' : 'w-full justify-center', className),
806
+ style: [variant === 'ambient' ? null : {
807
+ height: box
808
+ }, style],
809
+ children: content
810
+ });
811
+ }
812
+ Soundwave.displayName = 'Soundwave';
813
+ //# sourceMappingURL=index.js.map