@jjlmoya/utils-hardware 1.1.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 (85) hide show
  1. package/package.json +60 -0
  2. package/src/category/i18n/en.ts +107 -0
  3. package/src/category/i18n/es.ts +107 -0
  4. package/src/category/i18n/fr.ts +107 -0
  5. package/src/category/index.ts +18 -0
  6. package/src/category/seo.astro +15 -0
  7. package/src/components/PreviewNavSidebar.astro +116 -0
  8. package/src/components/PreviewToolbar.astro +143 -0
  9. package/src/data.ts +11 -0
  10. package/src/env.d.ts +5 -0
  11. package/src/index.ts +26 -0
  12. package/src/layouts/PreviewLayout.astro +117 -0
  13. package/src/pages/[locale]/[slug].astro +146 -0
  14. package/src/pages/[locale].astro +251 -0
  15. package/src/pages/index.astro +4 -0
  16. package/src/tests/faq_count.test.ts +19 -0
  17. package/src/tests/locale_completeness.test.ts +42 -0
  18. package/src/tests/mocks/astro_mock.js +2 -0
  19. package/src/tests/no_h1_in_components.test.ts +48 -0
  20. package/src/tests/schemas_fulfillment.test.ts +23 -0
  21. package/src/tests/seo_length.test.ts +23 -0
  22. package/src/tests/title_quality.test.ts +55 -0
  23. package/src/tests/tool_validation.test.ts +17 -0
  24. package/src/tool/batteryHealthEstimator/bibliography.astro +14 -0
  25. package/src/tool/batteryHealthEstimator/component.astro +606 -0
  26. package/src/tool/batteryHealthEstimator/i18n/en.ts +158 -0
  27. package/src/tool/batteryHealthEstimator/i18n/es.ts +158 -0
  28. package/src/tool/batteryHealthEstimator/i18n/fr.ts +158 -0
  29. package/src/tool/batteryHealthEstimator/index.ts +34 -0
  30. package/src/tool/batteryHealthEstimator/seo.astro +14 -0
  31. package/src/tool/batteryHealthEstimator/ui.ts +31 -0
  32. package/src/tool/deadPixelTest/bibliography.astro +31 -0
  33. package/src/tool/deadPixelTest/component.astro +717 -0
  34. package/src/tool/deadPixelTest/i18n/en.ts +218 -0
  35. package/src/tool/deadPixelTest/i18n/es.ts +218 -0
  36. package/src/tool/deadPixelTest/i18n/fr.ts +218 -0
  37. package/src/tool/deadPixelTest/index.ts +34 -0
  38. package/src/tool/deadPixelTest/seo.astro +14 -0
  39. package/src/tool/deadPixelTest/ui.ts +20 -0
  40. package/src/tool/gamepadTest/bibliography.astro +30 -0
  41. package/src/tool/gamepadTest/component.astro +1419 -0
  42. package/src/tool/gamepadTest/i18n/en.ts +186 -0
  43. package/src/tool/gamepadTest/i18n/es.ts +186 -0
  44. package/src/tool/gamepadTest/i18n/fr.ts +186 -0
  45. package/src/tool/gamepadTest/index.ts +34 -0
  46. package/src/tool/gamepadTest/seo.astro +14 -0
  47. package/src/tool/gamepadTest/ui.ts +57 -0
  48. package/src/tool/gamepadVibrationTester/bibliography.astro +14 -0
  49. package/src/tool/gamepadVibrationTester/component.astro +694 -0
  50. package/src/tool/gamepadVibrationTester/i18n/en.ts +155 -0
  51. package/src/tool/gamepadVibrationTester/i18n/es.ts +155 -0
  52. package/src/tool/gamepadVibrationTester/i18n/fr.ts +150 -0
  53. package/src/tool/gamepadVibrationTester/index.ts +34 -0
  54. package/src/tool/gamepadVibrationTester/seo.astro +14 -0
  55. package/src/tool/gamepadVibrationTester/ui.ts +28 -0
  56. package/src/tool/keyboardTest/bibliography.astro +30 -0
  57. package/src/tool/keyboardTest/component.astro +601 -0
  58. package/src/tool/keyboardTest/i18n/en.ts +146 -0
  59. package/src/tool/keyboardTest/i18n/es.ts +146 -0
  60. package/src/tool/keyboardTest/i18n/fr.ts +146 -0
  61. package/src/tool/keyboardTest/index.ts +34 -0
  62. package/src/tool/keyboardTest/seo.astro +14 -0
  63. package/src/tool/keyboardTest/ui.ts +8 -0
  64. package/src/tool/mousePollingTest/bibliography.astro +14 -0
  65. package/src/tool/mousePollingTest/component.astro +237 -0
  66. package/src/tool/mousePollingTest/i18n/en.ts +135 -0
  67. package/src/tool/mousePollingTest/i18n/es.ts +135 -0
  68. package/src/tool/mousePollingTest/i18n/fr.ts +135 -0
  69. package/src/tool/mousePollingTest/index.ts +34 -0
  70. package/src/tool/mousePollingTest/logic/MovingAverage.ts +20 -0
  71. package/src/tool/mousePollingTest/logic/RatonManager.ts +97 -0
  72. package/src/tool/mousePollingTest/logic/StatisticalFilter.ts +48 -0
  73. package/src/tool/mousePollingTest/seo.astro +14 -0
  74. package/src/tool/mousePollingTest/ui.ts +8 -0
  75. package/src/tool/mousePollingTest/worker/mouseWorker.ts +50 -0
  76. package/src/tool/toneGenerator/bibliography.astro +14 -0
  77. package/src/tool/toneGenerator/component.astro +571 -0
  78. package/src/tool/toneGenerator/i18n/en.ts +146 -0
  79. package/src/tool/toneGenerator/i18n/es.ts +146 -0
  80. package/src/tool/toneGenerator/i18n/fr.ts +146 -0
  81. package/src/tool/toneGenerator/index.ts +34 -0
  82. package/src/tool/toneGenerator/seo.astro +14 -0
  83. package/src/tool/toneGenerator/ui.ts +19 -0
  84. package/src/tools.ts +10 -0
  85. package/src/types.ts +72 -0
@@ -0,0 +1,694 @@
1
+ ---
2
+ import type { KnownLocale } from '../../types';
3
+ import type { ProbadorVibracionMandoUI } from './ui';
4
+ import { Icon } from 'astro-icon/components';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ ui?: Record<string, unknown>;
9
+ }
10
+
11
+ const { ui } = Astro.props;
12
+ const t = (ui ?? {}) as ProbadorVibracionMandoUI;
13
+ ---
14
+
15
+ <div id="pv-root" class="pv-wrapper"
16
+ data-s={JSON.stringify({ f: t.deviceFallback, cs: t.deviceConnectedSub, d: t.deviceDisconnected, ds: t.deviceDisconnectedSub })}>
17
+
18
+ <style is:global>
19
+ .pv-status-header.pv-connected {
20
+ background: rgba(16, 185, 129, 0.1);
21
+ border-color: rgba(16, 185, 129, 0.2);
22
+ box-shadow: inset 0 0 20px rgba(16, 185, 129, 0.1);
23
+ }
24
+
25
+ .pv-status-header.pv-connected .pv-status-avatar {
26
+ background: #10b981;
27
+ color: white;
28
+ box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
29
+ animation: pv-bounce-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
30
+ }
31
+
32
+ .pv-status-header.pv-connected .pv-indicator {
33
+ background: #10b981;
34
+ box-shadow: 0 0 10px rgba(16, 185, 129, 0.4);
35
+ }
36
+
37
+ .pv-tab-btn.pv-active {
38
+ background: var(--pv-surface);
39
+ color: var(--pv-accent);
40
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.07);
41
+ }
42
+
43
+ .pv-preset-card.pv-vibrating {
44
+ animation: pv-card-shake 0.1s infinite;
45
+ background: var(--pv-accent);
46
+ color: white;
47
+ border-color: var(--pv-accent);
48
+ }
49
+
50
+ .pv-preset-card.pv-vibrating .pv-preset-icon {
51
+ color: white;
52
+ background: rgba(255, 255, 255, 0.2);
53
+ }
54
+
55
+ .pv-preset-card.pv-vibrating .pv-preset-desc {
56
+ color: rgba(255, 255, 255, 0.8);
57
+ }
58
+
59
+ .pv-warning.pv-visible {
60
+ display: block;
61
+ }
62
+
63
+ .pv-wrapper {
64
+ --pv-accent: #8b5cf6;
65
+ --pv-accent-glow: rgba(139, 92, 246, 0.4);
66
+ --pv-accent-secondary: #ec4899;
67
+ --pv-surface: #fff;
68
+ --pv-surface-alt: #f1f5f9;
69
+ --pv-surface-hover: #e2e8f0;
70
+ --pv-border: rgba(0, 0, 0, 0.08);
71
+ --pv-text: #1e293b;
72
+ --pv-text-muted: #64748b;
73
+ --pv-danger: #ef4444;
74
+ }
75
+
76
+ .theme-dark .pv-wrapper {
77
+ --pv-surface: #0f172a;
78
+ --pv-surface-alt: rgba(30, 41, 59, 0.8);
79
+ --pv-surface-hover: rgba(51, 65, 85, 0.8);
80
+ --pv-border: rgba(255, 255, 255, 0.1);
81
+ --pv-text: #f8fafc;
82
+ --pv-text-muted: #94a3b8;
83
+ }
84
+
85
+ .theme-dark .pv-card {
86
+ box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.5);
87
+ }
88
+
89
+ .theme-dark .pv-warning {
90
+ background: rgba(245, 158, 11, 0.1);
91
+ color: #fcd34d;
92
+ }
93
+
94
+ .pv-tab-pane {
95
+ display: none;
96
+ animation: pv-fade-up 300ms ease;
97
+ }
98
+
99
+ .pv-tab-pane.pv-active {
100
+ display: flex;
101
+ flex-direction: column;
102
+ gap: 1.5rem;
103
+ }
104
+
105
+ @keyframes pv-bounce-in {
106
+ 0% {
107
+ transform: scale(0.5);
108
+ opacity: 0;
109
+ }
110
+ 80% {
111
+ transform: scale(1.1);
112
+ }
113
+ 100% {
114
+ transform: scale(1);
115
+ opacity: 1;
116
+ }
117
+ }
118
+
119
+ @keyframes pv-card-shake {
120
+ 0% { transform: translateX(0); }
121
+ 25% { transform: translateX(-2px) rotate(-1deg); }
122
+ 50% { transform: translateX(2px) rotate(1deg); }
123
+ 75% { transform: translateX(-1px) rotate(-1deg); }
124
+ 100% { transform: translateX(1px) rotate(1deg); }
125
+ }
126
+
127
+ @keyframes pv-fade-up {
128
+ from {
129
+ opacity: 0;
130
+ transform: translateY(10px);
131
+ }
132
+ to {
133
+ opacity: 1;
134
+ transform: translateY(0);
135
+ }
136
+ }
137
+ </style>
138
+
139
+ <div class="pv-card">
140
+
141
+ <div id="pv-status-head" class="pv-status-header">
142
+ <div class="pv-status-avatar">
143
+ <Icon name="mdi:gamepad-variant" class="pv-status-icon" />
144
+ </div>
145
+ <div class="pv-status-info">
146
+ <h3 id="pv-dev-name" class="pv-dev-name">{t.deviceDisconnected}</h3>
147
+ <p id="pv-dev-sub" class="pv-dev-sub">{t.deviceDisconnectedSub}</p>
148
+ </div>
149
+ <div class="pv-indicator"></div>
150
+ </div>
151
+
152
+ <div id="pv-warn" class="pv-warning">{t.noSupportWarning}</div>
153
+
154
+ <div class="pv-tabs-nav">
155
+ <button class="pv-tab-btn pv-active" data-target="presets">
156
+ <Icon name="mdi:flash" class="pv-tab-icon" />
157
+ {t.tabPresets}
158
+ </button>
159
+ <button class="pv-tab-btn" data-target="custom">
160
+ <Icon name="mdi:tune" class="pv-tab-icon" />
161
+ {t.tabCustom}
162
+ </button>
163
+ </div>
164
+
165
+ <div id="pv-pane-presets" class="pv-tab-pane pv-active">
166
+ <div class="pv-presets-grid">
167
+
168
+ <div class="pv-preset-card" data-id="heavy">
169
+ <div class="pv-preset-icon">
170
+ <Icon name="mdi:hammer" class="pv-preset-icon-svg" />
171
+ </div>
172
+ <div>
173
+ <p class="pv-preset-title">{t.presetHeavyTitle}</p>
174
+ <p class="pv-preset-desc">{t.presetHeavyDesc}</p>
175
+ </div>
176
+ </div>
177
+
178
+ <div class="pv-preset-card" data-id="light">
179
+ <div class="pv-preset-icon">
180
+ <Icon name="mdi:vibrate" class="pv-preset-icon-svg" />
181
+ </div>
182
+ <div>
183
+ <p class="pv-preset-title">{t.presetLightTitle}</p>
184
+ <p class="pv-preset-desc">{t.presetLightDesc}</p>
185
+ </div>
186
+ </div>
187
+
188
+ <div class="pv-preset-card" data-id="heartbeat">
189
+ <div class="pv-preset-icon">
190
+ <Icon name="mdi:heart-pulse" class="pv-preset-icon-svg" />
191
+ </div>
192
+ <div>
193
+ <p class="pv-preset-title">{t.presetHeartbeatTitle}</p>
194
+ <p class="pv-preset-desc">{t.presetHeartbeatDesc}</p>
195
+ </div>
196
+ </div>
197
+
198
+ <div class="pv-preset-card" data-id="song">
199
+ <div class="pv-preset-icon">
200
+ <Icon name="mdi:music-note" class="pv-preset-icon-svg" />
201
+ </div>
202
+ <div>
203
+ <p class="pv-preset-title">{t.presetSongTitle}</p>
204
+ <p class="pv-preset-desc">{t.presetSongDesc}</p>
205
+ </div>
206
+ </div>
207
+
208
+ <div class="pv-preset-card" data-id="rockyou">
209
+ <div class="pv-preset-icon">
210
+ <Icon name="mdi:lightning-bolt" class="pv-preset-icon-svg" />
211
+ </div>
212
+ <div>
213
+ <p class="pv-preset-title">{t.presetRockYouTitle}</p>
214
+ <p class="pv-preset-desc">{t.presetRockYouDesc}</p>
215
+ </div>
216
+ </div>
217
+
218
+ <div class="pv-preset-card" data-id="earthquake">
219
+ <div class="pv-preset-icon">
220
+ <Icon name="mdi:waveform" class="pv-preset-icon-svg" />
221
+ </div>
222
+ <div>
223
+ <p class="pv-preset-title">{t.presetEarthquakeTitle}</p>
224
+ <p class="pv-preset-desc">{t.presetEarthquakeDesc}</p>
225
+ </div>
226
+ </div>
227
+
228
+ </div>
229
+ </div>
230
+
231
+ <div id="pv-pane-custom" class="pv-tab-pane">
232
+ <div class="pv-custom-sliders">
233
+
234
+ <div class="pv-slider-group">
235
+ <div class="pv-slider-header">
236
+ <span>{t.customStrongLabel}</span>
237
+ <span id="pv-sl-str" class="pv-slider-val">100%</span>
238
+ </div>
239
+ <input type="range" class="pv-slider" id="pv-inp-str" min="0" max="1" step="0.05" value="1.0" />
240
+ </div>
241
+
242
+ <div class="pv-slider-group">
243
+ <div class="pv-slider-header">
244
+ <span>{t.customWeakLabel}</span>
245
+ <span id="pv-sl-wk" class="pv-slider-val">100%</span>
246
+ </div>
247
+ <input type="range" class="pv-slider" id="pv-inp-wk" min="0" max="1" step="0.05" value="1.0" />
248
+ </div>
249
+
250
+ <div class="pv-slider-group">
251
+ <div class="pv-slider-header">
252
+ <span>{t.customDurationLabel}</span>
253
+ <span id="pv-sl-dur" class="pv-slider-val">500ms</span>
254
+ </div>
255
+ <input type="range" class="pv-slider" id="pv-inp-dur" min="50" max="2500" step="50" value="500" />
256
+ </div>
257
+
258
+ </div>
259
+
260
+ <button id="pv-btn-send" class="pv-btn-send">
261
+ <Icon name="mdi:play" class="pv-btn-send-icon" />
262
+ {t.btnSendSignal}
263
+ </button>
264
+ </div>
265
+
266
+ </div>
267
+
268
+ <style>
269
+ .pv-wrapper {
270
+ max-width: 650px;
271
+ margin: 0 auto;
272
+ color: var(--pv-text);
273
+ }
274
+
275
+ .pv-card {
276
+ background: var(--pv-surface);
277
+ border: 1px solid var(--pv-border);
278
+ border-radius: 1.5rem;
279
+ padding: 2rem;
280
+ box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.1);
281
+ display: flex;
282
+ flex-direction: column;
283
+ gap: 1.5rem;
284
+ }
285
+
286
+ .pv-status-header {
287
+ display: flex;
288
+ align-items: center;
289
+ gap: 1.25rem;
290
+ padding: 1.25rem;
291
+ background: var(--pv-surface-alt);
292
+ border-radius: 1rem;
293
+ border: 1px solid var(--pv-border);
294
+ transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
295
+ }
296
+
297
+ .pv-status-avatar {
298
+ width: 3rem;
299
+ height: 3rem;
300
+ border-radius: 0.75rem;
301
+ background: var(--pv-surface-hover);
302
+ display: flex;
303
+ align-items: center;
304
+ justify-content: center;
305
+ color: var(--pv-text-muted);
306
+ flex-shrink: 0;
307
+ transition: all 300ms;
308
+ }
309
+
310
+ .pv-status-icon {
311
+ width: 1.75rem;
312
+ height: 1.75rem;
313
+ }
314
+
315
+ .pv-status-info {
316
+ flex: 1;
317
+ min-width: 0;
318
+ }
319
+
320
+ .pv-dev-name {
321
+ margin: 0;
322
+ font-size: 1.1rem;
323
+ font-weight: 800;
324
+ white-space: nowrap;
325
+ overflow: hidden;
326
+ text-overflow: ellipsis;
327
+ }
328
+
329
+ .pv-dev-sub {
330
+ margin: 0.25rem 0 0;
331
+ font-size: 0.85rem;
332
+ color: var(--pv-text-muted);
333
+ }
334
+
335
+ .pv-indicator {
336
+ flex-shrink: 0;
337
+ width: 0.75rem;
338
+ height: 0.75rem;
339
+ border-radius: 9999px;
340
+ background: var(--pv-danger);
341
+ box-shadow: 0 0 10px rgba(239, 68, 68, 0.4);
342
+ transition: all 300ms;
343
+ }
344
+
345
+ .pv-warning {
346
+ font-size: 0.85rem;
347
+ color: #b45309;
348
+ background: #fef3c7;
349
+ padding: 0.75rem;
350
+ border-radius: 0.5rem;
351
+ border-left: 4px solid #f59e0b;
352
+ display: none;
353
+ }
354
+
355
+ .pv-tabs-nav {
356
+ display: flex;
357
+ background: var(--pv-surface-alt);
358
+ padding: 0.5rem;
359
+ border-radius: 1rem;
360
+ gap: 0.5rem;
361
+ }
362
+
363
+ .pv-tab-btn {
364
+ flex: 1;
365
+ display: flex;
366
+ align-items: center;
367
+ justify-content: center;
368
+ gap: 0.5rem;
369
+ padding: 0.75rem 1rem;
370
+ border: none;
371
+ background: transparent;
372
+ color: var(--pv-text-muted);
373
+ font-weight: 700;
374
+ font-size: 0.9rem;
375
+ border-radius: 0.75rem;
376
+ cursor: pointer;
377
+ transition: all 200ms;
378
+ }
379
+
380
+ .pv-tab-btn:hover {
381
+ color: var(--pv-text);
382
+ background: var(--pv-surface-hover);
383
+ }
384
+
385
+ .pv-tab-icon {
386
+ width: 1.125rem;
387
+ height: 1.125rem;
388
+ }
389
+
390
+ .pv-presets-grid {
391
+ display: grid;
392
+ grid-template-columns: repeat(2, 1fr);
393
+ gap: 1rem;
394
+ }
395
+
396
+ @media (max-width: 480px) {
397
+ .pv-presets-grid {
398
+ grid-template-columns: 1fr;
399
+ }
400
+ }
401
+
402
+ .pv-preset-card {
403
+ background: var(--pv-surface-alt);
404
+ border: 1px solid var(--pv-border);
405
+ border-radius: 1rem;
406
+ padding: 1.25rem;
407
+ display: flex;
408
+ flex-direction: column;
409
+ align-items: flex-start;
410
+ gap: 0.75rem;
411
+ cursor: pointer;
412
+ transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
413
+ position: relative;
414
+ overflow: hidden;
415
+ }
416
+
417
+ .pv-preset-card:hover {
418
+ transform: translateY(-2px);
419
+ border-color: var(--pv-accent);
420
+ box-shadow: 0 10px 20px rgba(139, 92, 246, 0.1);
421
+ }
422
+
423
+ .pv-preset-icon {
424
+ width: 2.5rem;
425
+ height: 2.5rem;
426
+ background: var(--pv-surface);
427
+ border-radius: 0.625rem;
428
+ display: flex;
429
+ align-items: center;
430
+ justify-content: center;
431
+ color: var(--pv-accent);
432
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
433
+ transition: all 200ms;
434
+ }
435
+
436
+ .pv-preset-icon-svg {
437
+ width: 1.5rem;
438
+ height: 1.5rem;
439
+ }
440
+
441
+ .pv-preset-title {
442
+ font-weight: 800;
443
+ font-size: 1rem;
444
+ margin: 0;
445
+ }
446
+
447
+ .pv-preset-desc {
448
+ font-size: 0.8rem;
449
+ color: var(--pv-text-muted);
450
+ margin: 0;
451
+ line-height: 1.4;
452
+ transition: color 200ms;
453
+ }
454
+
455
+ .pv-custom-sliders {
456
+ display: flex;
457
+ flex-direction: column;
458
+ gap: 1.25rem;
459
+ background: var(--pv-surface-alt);
460
+ padding: 1.5rem;
461
+ border-radius: 1rem;
462
+ border: 1px solid var(--pv-border);
463
+ }
464
+
465
+ .pv-slider-group {
466
+ display: flex;
467
+ flex-direction: column;
468
+ gap: 0.75rem;
469
+ }
470
+
471
+ .pv-slider-header {
472
+ display: flex;
473
+ justify-content: space-between;
474
+ align-items: center;
475
+ font-size: 0.9rem;
476
+ font-weight: 700;
477
+ }
478
+
479
+ .pv-slider-val {
480
+ background: var(--pv-surface);
481
+ padding: 0.25rem 0.5rem;
482
+ border-radius: 0.375rem;
483
+ font-size: 0.85rem;
484
+ font-weight: 700;
485
+ color: var(--pv-accent);
486
+ min-width: 3rem;
487
+ text-align: center;
488
+ }
489
+
490
+ .pv-slider {
491
+ -webkit-appearance: none;
492
+ appearance: none;
493
+ width: 100%;
494
+ height: 8px;
495
+ background: var(--pv-surface-hover);
496
+ border-radius: 4px;
497
+ outline: none;
498
+ }
499
+
500
+ .pv-slider::-webkit-slider-thumb {
501
+ -webkit-appearance: none;
502
+ appearance: none;
503
+ width: 1.5rem;
504
+ height: 1.5rem;
505
+ border-radius: 9999px;
506
+ background: var(--pv-accent);
507
+ cursor: pointer;
508
+ border: 3px solid var(--pv-surface);
509
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
510
+ transition: transform 100ms;
511
+ }
512
+
513
+ .pv-slider::-webkit-slider-thumb:hover {
514
+ transform: scale(1.15);
515
+ }
516
+
517
+ .pv-btn-send {
518
+ width: 100%;
519
+ padding: 1.25rem;
520
+ border-radius: 1rem;
521
+ border: none;
522
+ background: linear-gradient(135deg, var(--pv-accent), var(--pv-accent-secondary));
523
+ color: white;
524
+ font-size: 1.1rem;
525
+ font-weight: 900;
526
+ letter-spacing: 0.5px;
527
+ cursor: pointer;
528
+ box-shadow: 0 10px 25px var(--pv-accent-glow);
529
+ display: flex;
530
+ align-items: center;
531
+ justify-content: center;
532
+ gap: 0.75rem;
533
+ transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
534
+ }
535
+
536
+ .pv-btn-send:hover {
537
+ transform: translateY(-2px);
538
+ box-shadow: 0 15px 30px var(--pv-accent-glow);
539
+ }
540
+
541
+ .pv-btn-send:active {
542
+ transform: translateY(1px);
543
+ }
544
+
545
+ .pv-btn-send-icon {
546
+ width: 1.5rem;
547
+ height: 1.5rem;
548
+ }
549
+ </style>
550
+
551
+ <script>
552
+ interface VibPad {
553
+ vibrationActuator?: { playEffect: (t: string, p: object) => Promise<void> };
554
+ }
555
+ type VibStep = [number, number, number, number];
556
+ let activePad: Gamepad | null = null;
557
+ let seqInterval: ReturnType<typeof setInterval> | null = null;
558
+ let seqRunId = 0;
559
+ const S: Record<string, string> = JSON.parse(document.getElementById('pv-root')?.dataset?.s ?? '{}');
560
+ const PRESET_SEQ: Record<string, VibStep[]> = {
561
+ heavy: [[300, 0, 1, 0]],
562
+ light: [[500, 1, 0, 0]],
563
+ song: [[100, 0.8, 0, 150], [300, 1, 0, 0]],
564
+ earthquake: [[2000, 1, 1, 0]],
565
+ rockyou: [
566
+ [200, 0.2, 1, 150], [200, 0.2, 1, 200], [300, 1, 0.5, 500],
567
+ [200, 0.2, 1, 150], [200, 0.2, 1, 200], [300, 1, 0.5, 500],
568
+ [200, 0.2, 1, 150], [200, 0.2, 1, 200], [300, 1, 0.5, 500],
569
+ [200, 0.2, 1, 150], [200, 0.2, 1, 200], [300, 1, 0.5, 500],
570
+ ],
571
+ };
572
+ const statusHead = document.getElementById('pv-status-head');
573
+ const devName = document.getElementById('pv-dev-name');
574
+ const devSub = document.getElementById('pv-dev-sub');
575
+ const supportWarn = document.getElementById('pv-warn');
576
+ const btnSend = document.getElementById('pv-btn-send');
577
+ const inpStr = document.getElementById('pv-inp-str') as HTMLInputElement | null;
578
+ const inpWk = document.getElementById('pv-inp-wk') as HTMLInputElement | null;
579
+ const inpDur = document.getElementById('pv-inp-dur') as HTMLInputElement | null;
580
+ const slStr = document.getElementById('pv-sl-str');
581
+ const slWk = document.getElementById('pv-sl-wk');
582
+ const slDur = document.getElementById('pv-sl-dur');
583
+ const presetCards = document.querySelectorAll('.pv-preset-card');
584
+ const tabBtns = document.querySelectorAll('.pv-tab-btn');
585
+ const tabPanes = document.querySelectorAll('.pv-tab-pane');
586
+ function getVibPad(idx: number): VibPad | null {
587
+ return (navigator.getGamepads ? navigator.getGamepads() : [])[idx] as VibPad | null;
588
+ }
589
+ function fireRumble(dur: number, weak: number, strong: number) {
590
+ if (!activePad) return;
591
+ const pad = getVibPad(activePad.index);
592
+ if (pad?.vibrationActuator) {
593
+ void pad.vibrationActuator.playEffect('dual-rumble', { startDelay: 0, duration: dur, weakMagnitude: weak, strongMagnitude: strong }).catch(() => null);
594
+ } else if ('vibrate' in navigator) {
595
+ (navigator as Navigator & { vibrate: (d: number) => void }).vibrate(dur);
596
+ }
597
+ }
598
+ function haltEverything() {
599
+ seqRunId++;
600
+ if (seqInterval !== null) { clearInterval(seqInterval); seqInterval = null; }
601
+ fireRumble(0, 0, 0);
602
+ presetCards.forEach((c) => c.classList.remove('pv-vibrating'));
603
+ }
604
+ function checkSupport(pad: Gamepad) {
605
+ const vp = pad as unknown as VibPad;
606
+ if (!vp.vibrationActuator && !('vibrate' in navigator)) {
607
+ supportWarn?.classList.add('pv-visible');
608
+ } else {
609
+ supportWarn?.classList.remove('pv-visible');
610
+ }
611
+ }
612
+ function hookGamepad(pad: Gamepad) {
613
+ activePad = pad;
614
+ statusHead?.classList.add('pv-connected');
615
+ if (devName) devName.textContent = pad.id.split('(')[0]?.trim() || S.f || 'Gamepad';
616
+ if (devSub) devSub.textContent = S.cs || '';
617
+ checkSupport(pad);
618
+ }
619
+ function unhookGamepad() {
620
+ activePad = null;
621
+ statusHead?.classList.remove('pv-connected');
622
+ if (devName) devName.textContent = S.d || '';
623
+ if (devSub) devSub.textContent = S.ds || '';
624
+ supportWarn?.classList.remove('pv-visible');
625
+ }
626
+ tabBtns.forEach((btn) => {
627
+ btn.addEventListener('click', () => {
628
+ tabBtns.forEach((b) => b.classList.remove('pv-active'));
629
+ tabPanes.forEach((p) => p.classList.remove('pv-active'));
630
+ btn.classList.add('pv-active');
631
+ document.getElementById(`pv-pane-${(btn as HTMLElement).dataset.target}`)?.classList.add('pv-active');
632
+ });
633
+ });
634
+ function updateCustom() {
635
+ if (!inpStr || !inpWk || !inpDur) return;
636
+ if (slStr) slStr.textContent = `${Math.round(parseFloat(inpStr.value) * 100)}%`;
637
+ if (slWk) slWk.textContent = `${Math.round(parseFloat(inpWk.value) * 100)}%`;
638
+ if (slDur) slDur.textContent = `${inpDur.value}ms`;
639
+ }
640
+ inpStr?.addEventListener('input', updateCustom);
641
+ inpWk?.addEventListener('input', updateCustom);
642
+ inpDur?.addEventListener('input', updateCustom);
643
+ btnSend?.addEventListener('click', () => {
644
+ haltEverything();
645
+ if (!inpStr || !inpWk || !inpDur) return;
646
+ fireRumble(parseInt(inpDur.value, 10), parseFloat(inpWk.value), parseFloat(inpStr.value));
647
+ });
648
+ function runSequence(seq: VibStep[], el: Element) {
649
+ const runId = ++seqRunId;
650
+ let t = 0;
651
+ seq.forEach((entry, i) => {
652
+ const [dur, w, s, pause] = entry;
653
+ setTimeout(() => { if (seqRunId === runId && dur > 0) fireRumble(dur, w, s); }, t);
654
+ t += dur + pause;
655
+ if (i === seq.length - 1) {
656
+ setTimeout(() => { if (seqRunId === runId) el.classList.remove('pv-vibrating'); }, t);
657
+ }
658
+ });
659
+ }
660
+ function runHeartbeat(el: Element) {
661
+ const runId = ++seqRunId;
662
+ let ticks = 0;
663
+ seqInterval = setInterval(() => {
664
+ if (seqRunId !== runId) { clearInterval(seqInterval as ReturnType<typeof setInterval>); return; }
665
+ fireRumble(100, 0.5, 0.8);
666
+ setTimeout(() => { if (seqRunId === runId) fireRumble(150, 0.8, 1); }, 120);
667
+ ticks++;
668
+ if (ticks >= 3) {
669
+ clearInterval(seqInterval as ReturnType<typeof setInterval>);
670
+ seqInterval = null;
671
+ setTimeout(() => { if (seqRunId === runId) el.classList.remove('pv-vibrating'); }, 300);
672
+ }
673
+ }, 800);
674
+ }
675
+ presetCards.forEach((card) => {
676
+ card.addEventListener('click', () => {
677
+ haltEverything();
678
+ card.classList.add('pv-vibrating');
679
+ const id = (card as HTMLElement).dataset.id ?? '';
680
+ if (id === 'heartbeat') { runHeartbeat(card); return; }
681
+ const seq = PRESET_SEQ[id];
682
+ if (seq) { runSequence(seq, card); return; }
683
+ card.classList.remove('pv-vibrating');
684
+ });
685
+ });
686
+ window.addEventListener('gamepadconnected', (e: GamepadEvent) => hookGamepad(e.gamepad));
687
+ window.addEventListener('gamepaddisconnected', (e: GamepadEvent) => {
688
+ if (activePad?.index === e.gamepad.index) unhookGamepad();
689
+ });
690
+ window.addEventListener('load', () => {
691
+ (navigator.getGamepads ? navigator.getGamepads() : []).forEach((p) => { if (p && !activePad) hookGamepad(p); });
692
+ });
693
+ </script>
694
+ </div>