web-haptic-engine 1.0.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.
- package/LICENSE +9 -0
- package/README.md +328 -0
- package/dist/index.d.mts +151 -0
- package/dist/index.mjs +1047 -0
- package/package.json +61 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
//#region src/core/easings.ts
|
|
2
|
+
const easings = {
|
|
3
|
+
linear: (t) => t,
|
|
4
|
+
easeIn: (t) => t * t,
|
|
5
|
+
easeOut: (t) => t * (2 - t),
|
|
6
|
+
easeInOut: (t) => t < .5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
|
|
7
|
+
bounce: (t) => {
|
|
8
|
+
if (t < 1 / 2.75) return 7.5625 * t * t;
|
|
9
|
+
if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + .75;
|
|
10
|
+
if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + .9375;
|
|
11
|
+
return 7.5625 * (t -= 2.625 / 2.75) * t + .984375;
|
|
12
|
+
},
|
|
13
|
+
spring: (t) => 1 - Math.cos(t * 4.5 * Math.PI) * Math.exp(-t * 6)
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/core/presets.ts
|
|
17
|
+
const presets = {
|
|
18
|
+
success: {
|
|
19
|
+
description: "Ascending double-tap",
|
|
20
|
+
pattern: [{
|
|
21
|
+
duration: 30,
|
|
22
|
+
intensity: .5
|
|
23
|
+
}, {
|
|
24
|
+
delay: 65,
|
|
25
|
+
duration: 45,
|
|
26
|
+
intensity: 1
|
|
27
|
+
}],
|
|
28
|
+
iosTicks: 2,
|
|
29
|
+
iosTickGap: 80,
|
|
30
|
+
impulse: "confirm"
|
|
31
|
+
},
|
|
32
|
+
warning: {
|
|
33
|
+
description: "Two hesitant taps",
|
|
34
|
+
pattern: [{
|
|
35
|
+
duration: 40,
|
|
36
|
+
intensity: .7
|
|
37
|
+
}, {
|
|
38
|
+
delay: 120,
|
|
39
|
+
duration: 35,
|
|
40
|
+
intensity: .5
|
|
41
|
+
}],
|
|
42
|
+
iosTicks: 2,
|
|
43
|
+
iosTickGap: 140,
|
|
44
|
+
impulse: "harsh"
|
|
45
|
+
},
|
|
46
|
+
error: {
|
|
47
|
+
description: "Three rapid harsh taps",
|
|
48
|
+
pattern: [
|
|
49
|
+
{
|
|
50
|
+
duration: 45,
|
|
51
|
+
intensity: .9
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
delay: 50,
|
|
55
|
+
duration: 45,
|
|
56
|
+
intensity: .9
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
delay: 50,
|
|
60
|
+
duration: 45,
|
|
61
|
+
intensity: .9
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
iosTicks: 3,
|
|
65
|
+
iosTickGap: 65,
|
|
66
|
+
impulse: "harsh"
|
|
67
|
+
},
|
|
68
|
+
confirm: {
|
|
69
|
+
description: "Strong double-tap confirm",
|
|
70
|
+
pattern: [{
|
|
71
|
+
duration: 35,
|
|
72
|
+
intensity: .9
|
|
73
|
+
}, {
|
|
74
|
+
delay: 100,
|
|
75
|
+
duration: 50,
|
|
76
|
+
intensity: 1
|
|
77
|
+
}],
|
|
78
|
+
iosTicks: 2,
|
|
79
|
+
iosTickGap: 110,
|
|
80
|
+
impulse: "confirm"
|
|
81
|
+
},
|
|
82
|
+
reject: {
|
|
83
|
+
description: "Harsh staccato triple",
|
|
84
|
+
pattern: [
|
|
85
|
+
{
|
|
86
|
+
duration: 30,
|
|
87
|
+
intensity: 1
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
delay: 30,
|
|
91
|
+
duration: 30,
|
|
92
|
+
intensity: 1
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
delay: 30,
|
|
96
|
+
duration: 50,
|
|
97
|
+
intensity: 1
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
iosTicks: 3,
|
|
101
|
+
iosTickGap: 45,
|
|
102
|
+
impulse: "harsh"
|
|
103
|
+
},
|
|
104
|
+
light: {
|
|
105
|
+
description: "Single light tap",
|
|
106
|
+
pattern: [{
|
|
107
|
+
duration: 12,
|
|
108
|
+
intensity: .35
|
|
109
|
+
}],
|
|
110
|
+
iosTicks: 1,
|
|
111
|
+
impulse: "tick"
|
|
112
|
+
},
|
|
113
|
+
medium: {
|
|
114
|
+
description: "Moderate tap",
|
|
115
|
+
pattern: [{
|
|
116
|
+
duration: 28,
|
|
117
|
+
intensity: .65
|
|
118
|
+
}],
|
|
119
|
+
iosTicks: 1,
|
|
120
|
+
impulse: "tap"
|
|
121
|
+
},
|
|
122
|
+
heavy: {
|
|
123
|
+
description: "Strong tap",
|
|
124
|
+
pattern: [{
|
|
125
|
+
duration: 40,
|
|
126
|
+
intensity: 1
|
|
127
|
+
}],
|
|
128
|
+
iosTicks: 2,
|
|
129
|
+
iosTickGap: 30,
|
|
130
|
+
impulse: "thud"
|
|
131
|
+
},
|
|
132
|
+
soft: {
|
|
133
|
+
description: "Cushioned tap",
|
|
134
|
+
pattern: [{
|
|
135
|
+
duration: 45,
|
|
136
|
+
intensity: .45
|
|
137
|
+
}],
|
|
138
|
+
iosTicks: 1,
|
|
139
|
+
impulse: "tap"
|
|
140
|
+
},
|
|
141
|
+
rigid: {
|
|
142
|
+
description: "Hard crisp snap",
|
|
143
|
+
pattern: [{
|
|
144
|
+
duration: 8,
|
|
145
|
+
intensity: 1
|
|
146
|
+
}],
|
|
147
|
+
iosTicks: 1,
|
|
148
|
+
impulse: "snap"
|
|
149
|
+
},
|
|
150
|
+
selection: {
|
|
151
|
+
description: "Subtle tick",
|
|
152
|
+
pattern: [{
|
|
153
|
+
duration: 6,
|
|
154
|
+
intensity: .25
|
|
155
|
+
}],
|
|
156
|
+
iosTicks: 1,
|
|
157
|
+
impulse: "tick"
|
|
158
|
+
},
|
|
159
|
+
tick: {
|
|
160
|
+
description: "Crisp tick",
|
|
161
|
+
pattern: [{
|
|
162
|
+
duration: 10,
|
|
163
|
+
intensity: .5
|
|
164
|
+
}],
|
|
165
|
+
iosTicks: 1,
|
|
166
|
+
impulse: "click"
|
|
167
|
+
},
|
|
168
|
+
click: {
|
|
169
|
+
description: "Ultra-short click",
|
|
170
|
+
pattern: [{
|
|
171
|
+
duration: 5,
|
|
172
|
+
intensity: .8
|
|
173
|
+
}],
|
|
174
|
+
iosTicks: 1,
|
|
175
|
+
impulse: "click"
|
|
176
|
+
},
|
|
177
|
+
snap: {
|
|
178
|
+
description: "Sharp snap",
|
|
179
|
+
pattern: [{
|
|
180
|
+
duration: 6,
|
|
181
|
+
intensity: 1
|
|
182
|
+
}],
|
|
183
|
+
iosTicks: 1,
|
|
184
|
+
impulse: "snap"
|
|
185
|
+
},
|
|
186
|
+
nudge: {
|
|
187
|
+
description: "Two quick taps",
|
|
188
|
+
pattern: [{
|
|
189
|
+
duration: 60,
|
|
190
|
+
intensity: .7
|
|
191
|
+
}, {
|
|
192
|
+
delay: 70,
|
|
193
|
+
duration: 40,
|
|
194
|
+
intensity: .3
|
|
195
|
+
}],
|
|
196
|
+
iosTicks: 2,
|
|
197
|
+
iosTickGap: 90,
|
|
198
|
+
impulse: "tap"
|
|
199
|
+
},
|
|
200
|
+
buzz: {
|
|
201
|
+
description: "Sustained vibration",
|
|
202
|
+
pattern: [{
|
|
203
|
+
duration: 800,
|
|
204
|
+
intensity: 1
|
|
205
|
+
}],
|
|
206
|
+
iosTicks: 8,
|
|
207
|
+
iosTickGap: 50,
|
|
208
|
+
impulse: "buzz"
|
|
209
|
+
},
|
|
210
|
+
heartbeat: {
|
|
211
|
+
description: "Heartbeat rhythm",
|
|
212
|
+
pattern: [
|
|
213
|
+
{
|
|
214
|
+
duration: 35,
|
|
215
|
+
intensity: .8
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
delay: 80,
|
|
219
|
+
duration: 25,
|
|
220
|
+
intensity: .5
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
delay: 300,
|
|
224
|
+
duration: 35,
|
|
225
|
+
intensity: .8
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
delay: 80,
|
|
229
|
+
duration: 25,
|
|
230
|
+
intensity: .5
|
|
231
|
+
}
|
|
232
|
+
],
|
|
233
|
+
iosTicks: 4,
|
|
234
|
+
iosTickGap: 0,
|
|
235
|
+
impulse: "thud"
|
|
236
|
+
},
|
|
237
|
+
spring: {
|
|
238
|
+
description: "Bouncy pulses",
|
|
239
|
+
pattern: [
|
|
240
|
+
{
|
|
241
|
+
duration: 40,
|
|
242
|
+
intensity: 1
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
delay: 50,
|
|
246
|
+
duration: 30,
|
|
247
|
+
intensity: .7
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
delay: 45,
|
|
251
|
+
duration: 20,
|
|
252
|
+
intensity: .45
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
delay: 40,
|
|
256
|
+
duration: 12,
|
|
257
|
+
intensity: .25
|
|
258
|
+
}
|
|
259
|
+
],
|
|
260
|
+
iosTicks: 4,
|
|
261
|
+
iosTickGap: 55,
|
|
262
|
+
impulse: "tap",
|
|
263
|
+
easing: "bounce"
|
|
264
|
+
},
|
|
265
|
+
rampUp: {
|
|
266
|
+
description: "Escalating",
|
|
267
|
+
pattern: [
|
|
268
|
+
{
|
|
269
|
+
duration: 12,
|
|
270
|
+
intensity: .2
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
delay: 50,
|
|
274
|
+
duration: 18,
|
|
275
|
+
intensity: .4
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
delay: 45,
|
|
279
|
+
duration: 25,
|
|
280
|
+
intensity: .65
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
delay: 40,
|
|
284
|
+
duration: 35,
|
|
285
|
+
intensity: 1
|
|
286
|
+
}
|
|
287
|
+
],
|
|
288
|
+
iosTicks: 4,
|
|
289
|
+
iosTickGap: 60,
|
|
290
|
+
impulse: "tap",
|
|
291
|
+
easing: "easeIn"
|
|
292
|
+
},
|
|
293
|
+
rampDown: {
|
|
294
|
+
description: "Decreasing",
|
|
295
|
+
pattern: [
|
|
296
|
+
{
|
|
297
|
+
duration: 35,
|
|
298
|
+
intensity: 1
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
delay: 45,
|
|
302
|
+
duration: 25,
|
|
303
|
+
intensity: .6
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
delay: 50,
|
|
307
|
+
duration: 15,
|
|
308
|
+
intensity: .3
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
delay: 55,
|
|
312
|
+
duration: 8,
|
|
313
|
+
intensity: .15
|
|
314
|
+
}
|
|
315
|
+
],
|
|
316
|
+
iosTicks: 3,
|
|
317
|
+
iosTickGap: 65,
|
|
318
|
+
impulse: "tap",
|
|
319
|
+
easing: "easeOut"
|
|
320
|
+
},
|
|
321
|
+
thud: {
|
|
322
|
+
description: "Heavy impact",
|
|
323
|
+
pattern: [{
|
|
324
|
+
duration: 50,
|
|
325
|
+
intensity: 1
|
|
326
|
+
}, {
|
|
327
|
+
delay: 40,
|
|
328
|
+
duration: 25,
|
|
329
|
+
intensity: .35
|
|
330
|
+
}],
|
|
331
|
+
iosTicks: 2,
|
|
332
|
+
iosTickGap: 50,
|
|
333
|
+
impulse: "thud"
|
|
334
|
+
},
|
|
335
|
+
trill: {
|
|
336
|
+
description: "Rapid flutter",
|
|
337
|
+
pattern: [
|
|
338
|
+
{
|
|
339
|
+
duration: 15,
|
|
340
|
+
intensity: .8
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
delay: 25,
|
|
344
|
+
duration: 15,
|
|
345
|
+
intensity: .8
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
delay: 25,
|
|
349
|
+
duration: 15,
|
|
350
|
+
intensity: .8
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
delay: 25,
|
|
354
|
+
duration: 15,
|
|
355
|
+
intensity: .8
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
delay: 25,
|
|
359
|
+
duration: 15,
|
|
360
|
+
intensity: .8
|
|
361
|
+
}
|
|
362
|
+
],
|
|
363
|
+
iosTicks: 5,
|
|
364
|
+
iosTickGap: 35,
|
|
365
|
+
impulse: "click"
|
|
366
|
+
},
|
|
367
|
+
pulse: {
|
|
368
|
+
description: "Rhythmic pulse",
|
|
369
|
+
pattern: [
|
|
370
|
+
{
|
|
371
|
+
duration: 30,
|
|
372
|
+
intensity: .6
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
delay: 150,
|
|
376
|
+
duration: 30,
|
|
377
|
+
intensity: .6
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
delay: 150,
|
|
381
|
+
duration: 30,
|
|
382
|
+
intensity: .6
|
|
383
|
+
}
|
|
384
|
+
],
|
|
385
|
+
iosTicks: 3,
|
|
386
|
+
iosTickGap: 180,
|
|
387
|
+
impulse: "tap"
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
//#endregion
|
|
391
|
+
//#region src/core/constants.ts
|
|
392
|
+
/** Maximum duration for a single vibration segment (ms). */
|
|
393
|
+
const MAX_SEG_MS = 1e3;
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/audio/impulse-library.ts
|
|
396
|
+
var ImpulseLibrary = class {
|
|
397
|
+
buffers = /* @__PURE__ */ new Map();
|
|
398
|
+
constructor(ctx) {
|
|
399
|
+
this.ctx = ctx;
|
|
400
|
+
this.build();
|
|
401
|
+
}
|
|
402
|
+
build() {
|
|
403
|
+
const sr = this.ctx.sampleRate;
|
|
404
|
+
this.mk("tick", sr, .008, (t) => {
|
|
405
|
+
const e = Math.exp(-t * 600);
|
|
406
|
+
return Math.sin(2 * Math.PI * 320 * t) * e + Math.sin(2 * Math.PI * 800 * t) * e * .15;
|
|
407
|
+
});
|
|
408
|
+
this.mk("tap", sr, .02, (t) => {
|
|
409
|
+
const e = Math.exp(-t * 250);
|
|
410
|
+
return Math.sin(2 * Math.PI * 220 * t) * e + (t < .001 ? Math.sin(2 * Math.PI * 800 * t) * (1 - t / .001) * .4 : 0) + Math.sin(2 * Math.PI * 110 * t) * e * .3;
|
|
411
|
+
});
|
|
412
|
+
this.mk("thud", sr, .035, (t) => {
|
|
413
|
+
const e = Math.exp(-t * 150);
|
|
414
|
+
return Math.sin(2 * Math.PI * 160 * t) * e + Math.sin(2 * Math.PI * 80 * t) * e * .5 + (t < .002 ? Math.sin(2 * Math.PI * 600 * t) * (1 - t / .002) * .3 : 0);
|
|
415
|
+
});
|
|
416
|
+
this.mk("click", sr, .005, (t) => {
|
|
417
|
+
const e = Math.exp(-t * 1200);
|
|
418
|
+
return Math.sin(2 * Math.PI * 400 * t) * e + Math.sin(2 * Math.PI * 1200 * t) * e * .1;
|
|
419
|
+
});
|
|
420
|
+
this.mk("snap", sr, .01, (t) => {
|
|
421
|
+
const e = Math.exp(-t * 500);
|
|
422
|
+
return Math.sin(2 * Math.PI * 500 * t) * e + Math.sin(2 * Math.PI * 1380 * t) * e * .2;
|
|
423
|
+
});
|
|
424
|
+
this.mk("buzz", sr, .06, (t) => {
|
|
425
|
+
const e = Math.exp(-t * 40);
|
|
426
|
+
let s = 0;
|
|
427
|
+
for (let h = 1; h <= 5; h++) s += Math.sin(2 * Math.PI * 200 * h * t) / h;
|
|
428
|
+
return s * e * .4;
|
|
429
|
+
});
|
|
430
|
+
this.mk("confirm", sr, .025, (t) => {
|
|
431
|
+
const e = Math.exp(-t * 200);
|
|
432
|
+
return (Math.sin(2 * Math.PI * 280 * t) * .6 + Math.sin(2 * Math.PI * 420 * t) * .4) * e;
|
|
433
|
+
});
|
|
434
|
+
this.mk("harsh", sr, .03, (t) => {
|
|
435
|
+
const f = 180, e = Math.exp(-t * 160);
|
|
436
|
+
let s = 0;
|
|
437
|
+
for (let h = 1; h <= 6; h++) s += Math.sin(2 * Math.PI * f * h * t) * (h % 2 === 0 ? .8 : 1) / h;
|
|
438
|
+
return (s + Math.sin(2 * Math.PI * f * 1.07 * t) * e * .3) * e * .35;
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/** Synthesize a named impulse buffer: peak-normalize samples from fn(t). */
|
|
442
|
+
mk(name, sr, dur, fn) {
|
|
443
|
+
const len = Math.ceil(sr * dur), buf = this.ctx.createBuffer(1, len, sr), d = buf.getChannelData(0);
|
|
444
|
+
let pk = 0;
|
|
445
|
+
for (let i = 0; i < len; i++) {
|
|
446
|
+
d[i] = fn(i / sr);
|
|
447
|
+
pk = Math.max(pk, Math.abs(d[i]));
|
|
448
|
+
}
|
|
449
|
+
if (pk > 0) for (let i = 0; i < len; i++) d[i] /= pk;
|
|
450
|
+
this.buffers.set(name, buf);
|
|
451
|
+
}
|
|
452
|
+
get(name) {
|
|
453
|
+
return this.buffers.get(name);
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/audio/audio-layer.ts
|
|
458
|
+
var AudioImpulseLayer = class {
|
|
459
|
+
ctx = null;
|
|
460
|
+
lib = null;
|
|
461
|
+
mg;
|
|
462
|
+
unlocked = false;
|
|
463
|
+
constructor(gain = .6) {
|
|
464
|
+
this.mg = gain;
|
|
465
|
+
}
|
|
466
|
+
/** Play a silent buffer to unlock the AudioContext (call from user gesture). */
|
|
467
|
+
unlock() {
|
|
468
|
+
if (this.unlocked) return;
|
|
469
|
+
try {
|
|
470
|
+
const ctx = this.ensureCtx();
|
|
471
|
+
const b = ctx.createBuffer(1, 1, ctx.sampleRate);
|
|
472
|
+
const s = ctx.createBufferSource();
|
|
473
|
+
s.buffer = b;
|
|
474
|
+
s.connect(ctx.destination);
|
|
475
|
+
s.start(0);
|
|
476
|
+
if (ctx.state === "suspended") ctx.resume();
|
|
477
|
+
this.unlocked = true;
|
|
478
|
+
} catch {}
|
|
479
|
+
}
|
|
480
|
+
ensureCtx() {
|
|
481
|
+
if (!this.ctx) {
|
|
482
|
+
this.ctx = new AudioContext();
|
|
483
|
+
this.lib = new ImpulseLibrary(this.ctx);
|
|
484
|
+
}
|
|
485
|
+
if (this.ctx.state === "suspended") this.ctx.resume();
|
|
486
|
+
return this.ctx;
|
|
487
|
+
}
|
|
488
|
+
/** Play a single impulse at the given intensity. */
|
|
489
|
+
fire(type, intensity) {
|
|
490
|
+
try {
|
|
491
|
+
const ctx = this.ensureCtx();
|
|
492
|
+
if (!this.lib) return;
|
|
493
|
+
const buf = this.lib.get(type);
|
|
494
|
+
if (!buf) return;
|
|
495
|
+
const src = ctx.createBufferSource();
|
|
496
|
+
const gain = ctx.createGain();
|
|
497
|
+
src.buffer = buf;
|
|
498
|
+
gain.gain.value = intensity * this.mg;
|
|
499
|
+
src.connect(gain);
|
|
500
|
+
gain.connect(ctx.destination);
|
|
501
|
+
src.start(0);
|
|
502
|
+
} catch {}
|
|
503
|
+
}
|
|
504
|
+
/** Play a sequence of impulses matching a Vibration[] pattern. */
|
|
505
|
+
fireSequence(vibs, type, intensity, ef) {
|
|
506
|
+
let elapsed = 0;
|
|
507
|
+
const count = vibs.length;
|
|
508
|
+
for (let i = 0; i < count; i++) {
|
|
509
|
+
const v = vibs[i];
|
|
510
|
+
const delay = elapsed + (v.delay ?? 0);
|
|
511
|
+
const si = v.intensity ?? intensity;
|
|
512
|
+
const pr = count > 1 ? i / (count - 1) : 1;
|
|
513
|
+
const ei = ef ? si * Math.max(.1, ef(pr)) : si;
|
|
514
|
+
if (delay <= 0) this.fire(type, ei);
|
|
515
|
+
else setTimeout(() => this.fire(type, ei), delay);
|
|
516
|
+
elapsed = delay + v.duration;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
setGain(g) {
|
|
520
|
+
this.mg = Math.max(0, Math.min(1, g));
|
|
521
|
+
}
|
|
522
|
+
destroy() {
|
|
523
|
+
this.ctx?.close();
|
|
524
|
+
this.ctx = null;
|
|
525
|
+
this.lib = null;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
//#endregion
|
|
529
|
+
//#region src/interactions/drag-haptics.ts
|
|
530
|
+
var DragHaptics = class {
|
|
531
|
+
engine;
|
|
532
|
+
opts;
|
|
533
|
+
curX = 0;
|
|
534
|
+
curY = 0;
|
|
535
|
+
lastFireX = 0;
|
|
536
|
+
lastFireY = 0;
|
|
537
|
+
lastFireT = 0;
|
|
538
|
+
active = false;
|
|
539
|
+
tickCount = 0;
|
|
540
|
+
cleanup = [];
|
|
541
|
+
/** Set during a touch sequence to prevent mouse handlers from double-firing. */
|
|
542
|
+
touchActive = false;
|
|
543
|
+
constructor(engine, options = {}) {
|
|
544
|
+
this.engine = engine;
|
|
545
|
+
this.opts = {
|
|
546
|
+
fireDist: options.fireDist ?? 18,
|
|
547
|
+
impulse: options.impulse ?? "tick",
|
|
548
|
+
intensity: options.intensity ?? .6,
|
|
549
|
+
onTick: options.onTick
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
/** Attach drag-haptic listeners to an element. Returns an unbind function. */
|
|
553
|
+
bind(element) {
|
|
554
|
+
const startDrag = (x, y) => {
|
|
555
|
+
this.curX = this.lastFireX = x;
|
|
556
|
+
this.curY = this.lastFireY = y;
|
|
557
|
+
this.lastFireT = performance.now();
|
|
558
|
+
this.active = true;
|
|
559
|
+
this.tickCount = 0;
|
|
560
|
+
this.engine.fireDragTickIfVibration(this.opts.intensity, 0);
|
|
561
|
+
this.engine.fireImpulse(this.opts.impulse, this.opts.intensity);
|
|
562
|
+
this.tickCount++;
|
|
563
|
+
this.opts.onTick?.(0, this.tickCount);
|
|
564
|
+
};
|
|
565
|
+
const moveDrag = (x, y) => {
|
|
566
|
+
if (!this.active) return;
|
|
567
|
+
this.curX = x;
|
|
568
|
+
this.curY = y;
|
|
569
|
+
const dist = this.distFromLastFire();
|
|
570
|
+
const elapsed = performance.now() - this.lastFireT;
|
|
571
|
+
if (dist >= this.opts.fireDist || dist >= 2 && elapsed >= 80) {
|
|
572
|
+
const dt = elapsed / 1e3;
|
|
573
|
+
const velocity = dt > .001 ? dist / dt : 0;
|
|
574
|
+
this.engine.fireDragTickIfVibration(this.opts.intensity, velocity);
|
|
575
|
+
this.engine.fireImpulse(this.opts.impulse, this.opts.intensity);
|
|
576
|
+
this.lastFireX = this.curX;
|
|
577
|
+
this.lastFireY = this.curY;
|
|
578
|
+
this.lastFireT = performance.now();
|
|
579
|
+
this.tickCount++;
|
|
580
|
+
this.opts.onTick?.(velocity, this.tickCount);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
const endDrag = () => {
|
|
584
|
+
if (!this.active) return;
|
|
585
|
+
this.active = false;
|
|
586
|
+
this.engine.trigger("selection", { intensity: this.opts.intensity });
|
|
587
|
+
};
|
|
588
|
+
const onTouchStart = (e) => {
|
|
589
|
+
this.touchActive = true;
|
|
590
|
+
const t = e.touches[0];
|
|
591
|
+
startDrag(t.clientX, t.clientY);
|
|
592
|
+
};
|
|
593
|
+
const onTouchMove = (e) => {
|
|
594
|
+
const t = e.touches[0];
|
|
595
|
+
moveDrag(t.clientX, t.clientY);
|
|
596
|
+
};
|
|
597
|
+
const onTouchEnd = () => {
|
|
598
|
+
endDrag();
|
|
599
|
+
setTimeout(() => {
|
|
600
|
+
this.touchActive = false;
|
|
601
|
+
}, 400);
|
|
602
|
+
};
|
|
603
|
+
const onTouchCancel = () => {
|
|
604
|
+
this.active = false;
|
|
605
|
+
this.touchActive = false;
|
|
606
|
+
};
|
|
607
|
+
const onMouseDown = (e) => {
|
|
608
|
+
if (this.touchActive) return;
|
|
609
|
+
if (e.button !== 0) return;
|
|
610
|
+
startDrag(e.clientX, e.clientY);
|
|
611
|
+
};
|
|
612
|
+
const onMouseMove = (e) => {
|
|
613
|
+
if (this.touchActive) return;
|
|
614
|
+
moveDrag(e.clientX, e.clientY);
|
|
615
|
+
};
|
|
616
|
+
const onMouseUp = () => {
|
|
617
|
+
if (this.touchActive) return;
|
|
618
|
+
endDrag();
|
|
619
|
+
};
|
|
620
|
+
element.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
621
|
+
element.addEventListener("touchmove", onTouchMove, { passive: true });
|
|
622
|
+
element.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
623
|
+
element.addEventListener("touchcancel", onTouchCancel, { passive: true });
|
|
624
|
+
element.addEventListener("mousedown", onMouseDown);
|
|
625
|
+
window.addEventListener("mousemove", onMouseMove);
|
|
626
|
+
window.addEventListener("mouseup", onMouseUp);
|
|
627
|
+
const unbind = () => {
|
|
628
|
+
element.removeEventListener("touchstart", onTouchStart);
|
|
629
|
+
element.removeEventListener("touchmove", onTouchMove);
|
|
630
|
+
element.removeEventListener("touchend", onTouchEnd);
|
|
631
|
+
element.removeEventListener("touchcancel", onTouchCancel);
|
|
632
|
+
element.removeEventListener("mousedown", onMouseDown);
|
|
633
|
+
window.removeEventListener("mousemove", onMouseMove);
|
|
634
|
+
window.removeEventListener("mouseup", onMouseUp);
|
|
635
|
+
this.active = false;
|
|
636
|
+
};
|
|
637
|
+
this.cleanup.push(unbind);
|
|
638
|
+
return unbind;
|
|
639
|
+
}
|
|
640
|
+
distFromLastFire() {
|
|
641
|
+
const dx = this.curX - this.lastFireX;
|
|
642
|
+
const dy = this.curY - this.lastFireY;
|
|
643
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
644
|
+
}
|
|
645
|
+
/** Unbind all elements and stop tracking. */
|
|
646
|
+
destroyAll() {
|
|
647
|
+
this.active = false;
|
|
648
|
+
this.cleanup.forEach((fn) => fn());
|
|
649
|
+
this.cleanup = [];
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
//#endregion
|
|
653
|
+
//#region src/platform/android.ts
|
|
654
|
+
function buildAndroidPattern(vibs, di, ef) {
|
|
655
|
+
const flat = [];
|
|
656
|
+
const count = vibs.length;
|
|
657
|
+
for (let idx = 0; idx < count; idx++) {
|
|
658
|
+
const v = vibs[idx];
|
|
659
|
+
let intensity = Math.max(0, Math.min(1, v.intensity ?? di));
|
|
660
|
+
if (ef && count > 1) intensity *= Math.max(.1, ef(idx / (count - 1)));
|
|
661
|
+
const sc = Math.max(1, Math.round(Math.min(v.duration, MAX_SEG_MS) * Math.max(.15, intensity)));
|
|
662
|
+
if (v.delay && v.delay > 0) if (!flat.length) flat.push(0, v.delay);
|
|
663
|
+
else if (flat.length % 2 === 0) flat[flat.length - 1] += v.delay;
|
|
664
|
+
else flat.push(v.delay);
|
|
665
|
+
if (flat.length > 0 && flat.length % 2 === 1) flat.push(0);
|
|
666
|
+
flat.push(sc);
|
|
667
|
+
}
|
|
668
|
+
return flat;
|
|
669
|
+
}
|
|
670
|
+
//#endregion
|
|
671
|
+
//#region src/platform/detection.ts
|
|
672
|
+
const hasVib = () => typeof navigator !== "undefined" && typeof navigator.vibrate === "function";
|
|
673
|
+
const isIOS = () => {
|
|
674
|
+
if (typeof document === "undefined") return false;
|
|
675
|
+
if (hasVib()) return false;
|
|
676
|
+
if (!("ontouchstart" in window) && !navigator.maxTouchPoints) return false;
|
|
677
|
+
try {
|
|
678
|
+
const el = document.createElement("input");
|
|
679
|
+
el.type = "checkbox";
|
|
680
|
+
el.setAttribute("switch", "");
|
|
681
|
+
return el.getAttribute("switch") !== null;
|
|
682
|
+
} catch {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
const detectPlatform = () => {
|
|
687
|
+
if (typeof window === "undefined") return "none";
|
|
688
|
+
if (hasVib()) return "vibration";
|
|
689
|
+
if (isIOS()) return "ios-switch";
|
|
690
|
+
return "none";
|
|
691
|
+
};
|
|
692
|
+
const supportsIOSHaptics = typeof document !== "undefined" ? isIOS() : false;
|
|
693
|
+
//#endregion
|
|
694
|
+
//#region src/platform/ios.ts
|
|
695
|
+
var IOSSwitchPool = class {
|
|
696
|
+
labels = [];
|
|
697
|
+
nextIndex = 0;
|
|
698
|
+
constructor() {
|
|
699
|
+
for (let i = 0; i < 6; i++) {
|
|
700
|
+
const id = `__hp${i}_${Date.now()}`;
|
|
701
|
+
const label = document.createElement("label");
|
|
702
|
+
label.setAttribute("for", id);
|
|
703
|
+
label.style.position = "fixed";
|
|
704
|
+
label.style.top = "0";
|
|
705
|
+
label.style.left = "0";
|
|
706
|
+
label.style.width = "1px";
|
|
707
|
+
label.style.height = "1px";
|
|
708
|
+
label.style.overflow = "hidden";
|
|
709
|
+
label.style.opacity = "0.01";
|
|
710
|
+
label.style.pointerEvents = "none";
|
|
711
|
+
label.style.userSelect = "none";
|
|
712
|
+
label.setAttribute("aria-hidden", "true");
|
|
713
|
+
const input = document.createElement("input");
|
|
714
|
+
input.type = "checkbox";
|
|
715
|
+
input.setAttribute("switch", "");
|
|
716
|
+
input.id = id;
|
|
717
|
+
input.tabIndex = -1;
|
|
718
|
+
input.style.all = "initial";
|
|
719
|
+
input.style.appearance = "auto";
|
|
720
|
+
label.appendChild(input);
|
|
721
|
+
document.body.appendChild(label);
|
|
722
|
+
this.labels.push(label);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/** Toggle the next switch in the pool to fire a single Taptic tick. */
|
|
726
|
+
fire() {
|
|
727
|
+
const label = this.labels[this.nextIndex];
|
|
728
|
+
this.nextIndex = (this.nextIndex + 1) % this.labels.length;
|
|
729
|
+
label.click();
|
|
730
|
+
}
|
|
731
|
+
destroy() {
|
|
732
|
+
for (const l of this.labels) l.remove();
|
|
733
|
+
this.labels = [];
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
function iosPlan(vibs, pr, di) {
|
|
737
|
+
if (pr?.iosTicks !== void 0) {
|
|
738
|
+
const bt = pr.iosTicks, sc = Math.max(1, Math.round(bt * Math.max(.25, di)));
|
|
739
|
+
const gap = pr.iosTickGap ?? 55;
|
|
740
|
+
if (gap === 0 && vibs.length > 1) {
|
|
741
|
+
const gs = [];
|
|
742
|
+
for (let i = 1; i < vibs.length && gs.length < sc - 1; i++) gs.push((vibs[i].delay ?? 0) + vibs[i - 1].duration);
|
|
743
|
+
return {
|
|
744
|
+
ticks: Math.min(sc, gs.length + 1),
|
|
745
|
+
gaps: gs
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
ticks: sc,
|
|
750
|
+
gaps: Array(Math.max(0, sc - 1)).fill(gap)
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
let t = 0;
|
|
754
|
+
const gs = [];
|
|
755
|
+
for (const v of vibs) if ((v.intensity ?? di) >= .12) {
|
|
756
|
+
if (t > 0) gs.push((v.delay ?? 0) + (vibs[t - 1]?.duration ?? 0));
|
|
757
|
+
t++;
|
|
758
|
+
}
|
|
759
|
+
const sc = Math.max(1, Math.round(t * Math.max(.25, di)));
|
|
760
|
+
return {
|
|
761
|
+
ticks: sc,
|
|
762
|
+
gaps: gs.slice(0, Math.max(0, sc - 1))
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
//#endregion
|
|
766
|
+
//#region src/haptic-engine.ts
|
|
767
|
+
var HapticEngine = class HapticEngine {
|
|
768
|
+
platform;
|
|
769
|
+
iosPool = null;
|
|
770
|
+
audio = null;
|
|
771
|
+
useAudio;
|
|
772
|
+
throttleMs;
|
|
773
|
+
enabled = true;
|
|
774
|
+
lastTriggerTime = 0;
|
|
775
|
+
activeAbort = null;
|
|
776
|
+
/** True if the device supports Android-style navigator.vibrate(). */
|
|
777
|
+
static supportsVibration = hasVib();
|
|
778
|
+
/** True if the device supports iOS switch-checkbox haptics. */
|
|
779
|
+
static supportsIOSHaptics = supportsIOSHaptics;
|
|
780
|
+
/** True if any form of haptic feedback is available. */
|
|
781
|
+
static isSupported = HapticEngine.supportsVibration || HapticEngine.supportsIOSHaptics;
|
|
782
|
+
constructor(options = {}) {
|
|
783
|
+
this.throttleMs = options.throttleMs ?? 25;
|
|
784
|
+
this.useAudio = options.audioLayer ?? true;
|
|
785
|
+
this.platform = detectPlatform();
|
|
786
|
+
if (this.platform === "ios-switch") this.iosPool = new IOSSwitchPool();
|
|
787
|
+
this.audio = new AudioImpulseLayer(options.audioGain ?? .6);
|
|
788
|
+
if (typeof document !== "undefined") {
|
|
789
|
+
const unlock = () => {
|
|
790
|
+
this.audio?.unlock();
|
|
791
|
+
};
|
|
792
|
+
document.addEventListener("touchstart", unlock, {
|
|
793
|
+
once: true,
|
|
794
|
+
passive: true,
|
|
795
|
+
capture: true
|
|
796
|
+
});
|
|
797
|
+
document.addEventListener("click", unlock, {
|
|
798
|
+
once: true,
|
|
799
|
+
capture: true
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Fire a haptic + audio pattern. Input can be a preset name, duration (ms),
|
|
805
|
+
* Vibration[], number[] (alternating on/off), or a HapticPreset object.
|
|
806
|
+
*/
|
|
807
|
+
async trigger(input, options) {
|
|
808
|
+
if (!this.enabled) return;
|
|
809
|
+
const now = performance.now();
|
|
810
|
+
if (now - this.lastTriggerTime < this.throttleMs) return;
|
|
811
|
+
this.lastTriggerTime = now;
|
|
812
|
+
this.cancel();
|
|
813
|
+
const r = this.resolveInput(input);
|
|
814
|
+
if (r.vibrations.length === 0) return;
|
|
815
|
+
const di = options?.intensity ?? .5;
|
|
816
|
+
const ef = r.preset?.easing ? easings[r.preset.easing] : void 0;
|
|
817
|
+
const sa = options?.audio ?? this.useAudio;
|
|
818
|
+
this.audio?.unlock();
|
|
819
|
+
if (sa && this.audio && r.preset?.impulse) this.audio.fireSequence(r.vibrations, r.preset.impulse, di, ef);
|
|
820
|
+
switch (this.platform) {
|
|
821
|
+
case "vibration":
|
|
822
|
+
await this.fireAndroid(r.vibrations, di, ef);
|
|
823
|
+
break;
|
|
824
|
+
case "ios-switch":
|
|
825
|
+
await this.fireIOS(r.vibrations, r.preset, di);
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
/** Play an audio impulse. Set force=true to bypass the audio-enabled toggle. */
|
|
830
|
+
fireImpulse(type, intensity, force) {
|
|
831
|
+
if ((force || this.useAudio) && this.audio) {
|
|
832
|
+
this.audio.unlock();
|
|
833
|
+
this.audio.fire(type, intensity);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/** Fire a single platform haptic tick (short vibrate or single iOS switch toggle). */
|
|
837
|
+
fireHapticTick(intensity) {
|
|
838
|
+
if (this.platform === "vibration") try {
|
|
839
|
+
navigator.vibrate(Math.max(1, Math.round(10 * intensity)));
|
|
840
|
+
} catch {}
|
|
841
|
+
else if (this.platform === "ios-switch" && this.iosPool) try {
|
|
842
|
+
this.iosPool.fire();
|
|
843
|
+
} catch {}
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Fire a drag-optimized haptic tick. Skips cancel()/vibrate(0) overhead.
|
|
847
|
+
* Android: velocity scales pulse duration (12-25ms) and count (1-3 taps).
|
|
848
|
+
* iOS: single switch toggle (requires user activation to produce Taptic).
|
|
849
|
+
*/
|
|
850
|
+
fireDragTick(intensity, velocity) {
|
|
851
|
+
const taps = Math.max(1, Math.min(3, Math.ceil(velocity / 400)));
|
|
852
|
+
if (this.platform === "vibration") {
|
|
853
|
+
const dur = Math.max(12, Math.min(25, Math.round(15 * intensity + velocity / 200)));
|
|
854
|
+
if (taps === 1) try {
|
|
855
|
+
navigator.vibrate(dur);
|
|
856
|
+
} catch {}
|
|
857
|
+
else {
|
|
858
|
+
const pat = [];
|
|
859
|
+
for (let i = 0; i < taps; i++) {
|
|
860
|
+
if (i > 0) pat.push(6);
|
|
861
|
+
pat.push(dur);
|
|
862
|
+
}
|
|
863
|
+
try {
|
|
864
|
+
navigator.vibrate(pat);
|
|
865
|
+
} catch {}
|
|
866
|
+
}
|
|
867
|
+
} else if (this.platform === "ios-switch" && this.iosPool) try {
|
|
868
|
+
this.iosPool.fire();
|
|
869
|
+
} catch {}
|
|
870
|
+
}
|
|
871
|
+
/** Fire a drag tick only on Android (vibration platform). No-op on iOS. */
|
|
872
|
+
fireDragTickIfVibration(intensity, velocity) {
|
|
873
|
+
if (this.platform !== "vibration") return;
|
|
874
|
+
this.fireDragTick(intensity, velocity);
|
|
875
|
+
}
|
|
876
|
+
/** Play a sequence of preset steps with optional delays and repeats. */
|
|
877
|
+
async sequence(steps, options) {
|
|
878
|
+
const repeat = options?.repeat ?? 1, repeatGap = options?.repeatGap ?? 200;
|
|
879
|
+
const ac = new AbortController();
|
|
880
|
+
this.activeAbort = ac;
|
|
881
|
+
for (let rep = 0; rep < repeat; rep++) {
|
|
882
|
+
if (ac.signal.aborted) return;
|
|
883
|
+
for (const step of steps) {
|
|
884
|
+
if (ac.signal.aborted) return;
|
|
885
|
+
if (step.delay && step.delay > 0) {
|
|
886
|
+
await this.sleep(step.delay, ac.signal);
|
|
887
|
+
if (ac.signal.aborted) return;
|
|
888
|
+
}
|
|
889
|
+
this.lastTriggerTime = 0;
|
|
890
|
+
await this.trigger(step.preset, { intensity: step.intensity });
|
|
891
|
+
}
|
|
892
|
+
if (rep < repeat - 1 && repeatGap > 0) await this.sleep(repeatGap, ac.signal);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
/** Create a DragHaptics instance bound to this engine. */
|
|
896
|
+
drag(options) {
|
|
897
|
+
return new DragHaptics(this, options);
|
|
898
|
+
}
|
|
899
|
+
/** Cancel any in-progress pattern or vibration. */
|
|
900
|
+
cancel() {
|
|
901
|
+
this.activeAbort?.abort();
|
|
902
|
+
this.activeAbort = null;
|
|
903
|
+
if (this.platform === "vibration" && hasVib()) navigator.vibrate(0);
|
|
904
|
+
}
|
|
905
|
+
setEnabled(e) {
|
|
906
|
+
this.enabled = e;
|
|
907
|
+
if (!e) this.cancel();
|
|
908
|
+
}
|
|
909
|
+
get isEnabled() {
|
|
910
|
+
return this.enabled;
|
|
911
|
+
}
|
|
912
|
+
setAudioLayer(e) {
|
|
913
|
+
this.useAudio = e;
|
|
914
|
+
}
|
|
915
|
+
setAudioGain(g) {
|
|
916
|
+
this.audio?.setGain(g);
|
|
917
|
+
}
|
|
918
|
+
setThrottle(ms) {
|
|
919
|
+
this.throttleMs = Math.max(0, ms);
|
|
920
|
+
}
|
|
921
|
+
registerPreset(name, preset) {
|
|
922
|
+
presets[name] = preset;
|
|
923
|
+
}
|
|
924
|
+
/** Clean up all resources (audio context, iOS DOM elements). */
|
|
925
|
+
destroy() {
|
|
926
|
+
this.cancel();
|
|
927
|
+
this.iosPool?.destroy();
|
|
928
|
+
this.iosPool = null;
|
|
929
|
+
this.audio?.destroy();
|
|
930
|
+
this.audio = null;
|
|
931
|
+
}
|
|
932
|
+
/** Resolve any HapticInput into a Vibration[] and optional preset metadata. */
|
|
933
|
+
resolveInput(input) {
|
|
934
|
+
if (input == null) return {
|
|
935
|
+
vibrations: presets.medium.pattern,
|
|
936
|
+
preset: presets.medium
|
|
937
|
+
};
|
|
938
|
+
if (typeof input === "string") {
|
|
939
|
+
const p = presets[input];
|
|
940
|
+
if (!p) {
|
|
941
|
+
console.warn(`[haptics] Unknown: "${input}"`);
|
|
942
|
+
return {
|
|
943
|
+
vibrations: [],
|
|
944
|
+
preset: null
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
vibrations: p.pattern,
|
|
949
|
+
preset: p
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
if (typeof input === "number") return {
|
|
953
|
+
vibrations: [{ duration: input }],
|
|
954
|
+
preset: null
|
|
955
|
+
};
|
|
956
|
+
if (typeof input === "object" && !Array.isArray(input) && "pattern" in input) return {
|
|
957
|
+
vibrations: input.pattern,
|
|
958
|
+
preset: input
|
|
959
|
+
};
|
|
960
|
+
if (Array.isArray(input)) {
|
|
961
|
+
if (!input.length) return {
|
|
962
|
+
vibrations: [],
|
|
963
|
+
preset: null
|
|
964
|
+
};
|
|
965
|
+
if (typeof input[0] === "number") {
|
|
966
|
+
const r = [];
|
|
967
|
+
let pd = 0;
|
|
968
|
+
for (let i = 0; i < input.length; i++) if (i % 2 === 0) {
|
|
969
|
+
r.push({
|
|
970
|
+
duration: input[i],
|
|
971
|
+
...pd > 0 ? { delay: pd } : {}
|
|
972
|
+
});
|
|
973
|
+
pd = 0;
|
|
974
|
+
} else pd = input[i];
|
|
975
|
+
return {
|
|
976
|
+
vibrations: r,
|
|
977
|
+
preset: null
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
return {
|
|
981
|
+
vibrations: input,
|
|
982
|
+
preset: null
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
vibrations: [],
|
|
987
|
+
preset: null
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
/** Android: convert Vibration[] to flat pattern and vibrate for its duration. */
|
|
991
|
+
async fireAndroid(vibs, di, ef) {
|
|
992
|
+
const pat = buildAndroidPattern(vibs, di, ef);
|
|
993
|
+
if (!pat.length) return;
|
|
994
|
+
navigator.vibrate(pat);
|
|
995
|
+
const total = pat.reduce((s, v) => s + v, 0);
|
|
996
|
+
if (total > 0) {
|
|
997
|
+
const ac = new AbortController();
|
|
998
|
+
this.activeAbort = ac;
|
|
999
|
+
await this.sleep(total, ac.signal);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
/** iOS: fire switch-checkbox toggles with gaps between them. */
|
|
1003
|
+
async fireIOS(vibs, pr, di) {
|
|
1004
|
+
if (!this.iosPool) return;
|
|
1005
|
+
const plan = iosPlan(vibs, pr, di);
|
|
1006
|
+
if (plan.ticks <= 0) return;
|
|
1007
|
+
const ac = new AbortController();
|
|
1008
|
+
this.activeAbort = ac;
|
|
1009
|
+
for (let i = 0; i < plan.ticks; i++) {
|
|
1010
|
+
if (ac.signal.aborted) return;
|
|
1011
|
+
this.iosPool.fire();
|
|
1012
|
+
if (i < plan.ticks - 1) {
|
|
1013
|
+
const gap = plan.gaps[i] ?? 55;
|
|
1014
|
+
if (gap > 0) {
|
|
1015
|
+
await this.sleep(gap, ac.signal);
|
|
1016
|
+
if (ac.signal.aborted) return;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
/** Cancellable setTimeout wrapped in a Promise. */
|
|
1022
|
+
sleep(ms, signal) {
|
|
1023
|
+
return new Promise((resolve) => {
|
|
1024
|
+
if (signal.aborted) {
|
|
1025
|
+
resolve();
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const t = setTimeout(resolve, ms);
|
|
1029
|
+
signal.addEventListener("abort", () => {
|
|
1030
|
+
clearTimeout(t);
|
|
1031
|
+
resolve();
|
|
1032
|
+
}, { once: true });
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
let _default = null;
|
|
1037
|
+
/** Get or create the shared default engine instance. */
|
|
1038
|
+
function getDefaultEngine(opts) {
|
|
1039
|
+
if (!_default) _default = new HapticEngine(opts);
|
|
1040
|
+
return _default;
|
|
1041
|
+
}
|
|
1042
|
+
/** Fire a one-shot haptic using the default engine. */
|
|
1043
|
+
async function haptic(input, options) {
|
|
1044
|
+
return getDefaultEngine().trigger(input, options);
|
|
1045
|
+
}
|
|
1046
|
+
//#endregion
|
|
1047
|
+
export { DragHaptics, HapticEngine, easings, getDefaultEngine, haptic, presets };
|