nuts / files / src / vibration.ts
vibration.ts
| 1 | import { CONFIG } from './config' |
| 2 | import { dbg } from './debug' |
| 3 | |
| 4 | let lastVibrationAt = 0 |
| 5 | let unlocked = false |
| 6 | let vibrateEnabled = true |
| 7 | let vibrateItems = true |
| 8 | let vibrateEdges = true |
| 9 | |
| 10 | export function setVibrateEnabled(on: boolean): void { |
| 11 | vibrateEnabled = on |
| 12 | } |
| 13 | |
| 14 | export function isVibrateEnabled(): boolean { |
| 15 | return vibrateEnabled |
| 16 | } |
| 17 | |
| 18 | export function setVibrateItems(on: boolean): void { vibrateItems = on } |
| 19 | export function isVibrateItems(): boolean { return vibrateItems } |
| 20 | export function setVibrateEdges(on: boolean): void { vibrateEdges = on } |
| 21 | export function isVibrateEdges(): boolean { return vibrateEdges } |
| 22 | |
| 23 | /** |
| 24 | * Call once from a user-gesture handler (e.g. the tap-to-start). |
| 25 | * Chrome requires at least one vibrate() call from a gesture context before |
| 26 | * it will honour subsequent calls from rAF / event callbacks. |
| 27 | */ |
| 28 | export function unlockVibration(): void { |
| 29 | if (!('vibrate' in navigator && typeof navigator.vibrate === 'function')) { |
| 30 | dbg('vibrate: API not present') |
| 31 | return |
| 32 | } |
| 33 | navigator.vibrate(1) |
| 34 | unlocked = true |
| 35 | dbg('vibrate: unlocked') |
| 36 | showVibrateHint() |
| 37 | } |
| 38 | |
| 39 | function showVibrateHint(): void { |
| 40 | const STORAGE_KEY = 'vibrate-hint-dismissed' |
| 41 | if (localStorage.getItem(STORAGE_KEY)) return |
| 42 | |
| 43 | const toast = document.createElement('div') |
| 44 | toast.id = 'vibration-hint' |
| 45 | toast.textContent = '📳 Tip: turn off Do Not Disturb for haptics' |
| 46 | document.body.appendChild(toast) |
| 47 | |
| 48 | function dismiss(): void { |
| 49 | localStorage.setItem(STORAGE_KEY, '1') |
| 50 | toast.classList.add('fade-out') |
| 51 | setTimeout(() => toast.remove(), 420) |
| 52 | } |
| 53 | |
| 54 | toast.addEventListener('click', dismiss) |
| 55 | setTimeout(dismiss, 5000) |
| 56 | } |
| 57 | |
| 58 | function canVibrate(): boolean { |
| 59 | return unlocked && 'vibrate' in navigator && typeof navigator.vibrate === 'function' |
| 60 | } |
| 61 | |
| 62 | /** Fire the Web Vibration API, with a minimum interval to avoid spam. */ |
| 63 | export function vibrate(durationMs: number): void { |
| 64 | if (!vibrateItems) return |
| 65 | const now = Date.now() |
| 66 | if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return |
| 67 | // Chrome won't vibrate if the page is backgrounded or display is off |
| 68 | if (document.visibilityState !== 'visible') return |
| 69 | lastVibrationAt = now |
| 70 | if (!vibrateEnabled) return |
| 71 | if (canVibrate()) { |
| 72 | const ok = navigator.vibrate(durationMs) |
| 73 | dbg('vibrate', durationMs, 'ms ->', ok) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | export function vibrateWall(durationMs: number): void { |
| 78 | if (!vibrateEdges) return |
| 79 | const now = Date.now() |
| 80 | if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return |
| 81 | if (document.visibilityState !== 'visible') return |
| 82 | lastVibrationAt = now |
| 83 | if (!vibrateEnabled) return |
| 84 | if (canVibrate()) navigator.vibrate(durationMs) |
| 85 | } |
| 86 | |
| 87 | /** Short double-pulse for a shake event. */ |
| 88 | export function vibrateShake(): void { |
| 89 | const now = Date.now() |
| 90 | if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return |
| 91 | lastVibrationAt = now |
| 92 | if (!vibrateEnabled) return |
| 93 | if (canVibrate()) { |
| 94 | navigator.vibrate([25, 15, 25]) |
| 95 | } |
| 96 | } |