fuzzyworld

nuts / commit

added vibration toggle and allow all platforms to run

4ea6baf726728143692e960e45a0d13bcb342421 · Pavel Safronov · 2026-04-12 12:33:37 -0700 · parents fd4adeec1065

added vibration toggle and allow all platforms to run
 index.html       |  1 +
 src/compat.ts    | 20 ++++++++------------
 src/ui.ts        | 16 ++++++++++++++++
 src/vibration.ts | 11 +++++++++++
 4 files changed, 36 insertions(+), 12 deletions(-)

diff --git a/index.html b/index.html
index 9ae4a9d..7255daa 100644
--- a/index.html
+++ b/index.html
@@ -79,6 +79,7 @@
             <h2>Settings</h2>
 
             <button id="test-vibrate-btn" class="action-btn">Test vibration</button>
+            <button id="vibrate-toggle-btn" class="action-btn active">Vibration: On</button>
             <button id="close-settings" class="close-btn">Done</button>
 
             <div class="share-section">
diff --git a/src/compat.ts b/src/compat.ts
index b35c5f7..e08244b 100644
--- a/src/compat.ts
+++ b/src/compat.ts
@@ -1,6 +1,11 @@
 export type CompatResult =
     | { ok: true }
-    | { ok: false; reason: 'no-touch' | 'no-motion' | 'no-vibration' }
+    | { ok: false; reason: 'no-touch' | 'no-motion' }
+
+/** True if the Vibration API is available (Chrome Android). */
+export function hasVibration(): boolean {
+    return 'vibrate' in navigator && typeof navigator.vibrate === 'function'
+}
 
 /**
  * Check whether this platform can run the full experience.
@@ -17,26 +22,17 @@ export function checkCompat(): CompatResult {
         return { ok: false, reason: 'no-motion' }
     }
 
-    // Must have the Vibration API
-    if (!('vibrate' in navigator) || typeof navigator.vibrate !== 'function') {
-        return { ok: false, reason: 'no-vibration' }
-    }
-
     return { ok: true }
 }
 
 const MESSAGES: Record<string, { headline: string; body: string }> = {
     'no-touch': {
         headline: 'Phone only',
-        body: 'This app needs a touchscreen to work.\nOpen it on your Android phone in Chrome.',
+        body: 'This app needs a touchscreen to work.\nOpen it on your phone in Chrome.',
     },
     'no-motion': {
         headline: 'No accelerometer',
-        body: 'This app needs a motion sensor to work.\nOpen it on your Android phone in Chrome.',
-    },
-    'no-vibration': {
-        headline: 'No haptics',
-        body: 'This app needs vibration to work.\nOpen it in Chrome on your Android phone\n— Firefox and iOS don\'t support haptics.',
+        body: 'This app needs a motion sensor to work.\nOpen it on your phone in Chrome.',
     },
 }
 
diff --git a/src/ui.ts b/src/ui.ts
index 34c2871..18a1c4d 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -1,6 +1,8 @@
 import { CONFIG } from './config'
 import { dbg } from './debug'
 import QRCode from 'qrcode'
+import { hasVibration } from './compat'
+import { setVibrateEnabled, isVibrateEnabled } from './vibration'
 
 export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut'
 
@@ -36,6 +38,7 @@ export function initUI(callbacks: UICallbacks): void {
 
     const settingsPanel = document.getElementById('settings-panel') as HTMLDivElement
     const testVibrateBtn = document.getElementById('test-vibrate-btn') as HTMLButtonElement
+    const vibrateToggleBtn = document.getElementById('vibrate-toggle-btn') as HTMLButtonElement
     const closeBtn = document.getElementById('close-settings') as HTMLButtonElement
     const backdrop = settingsPanel.querySelector('.panel-backdrop') as HTMLDivElement
     const qrCanvas = document.getElementById('qr-canvas') as HTMLCanvasElement
@@ -105,6 +108,12 @@ export function initUI(callbacks: UICallbacks): void {
     // ── Settings panel ───────────────────────────────────────────────────────
     toolbar.hidden = false
 
+    // Hide vibration controls on unsupported platforms
+    if (!hasVibration()) {
+        testVibrateBtn.hidden = true
+        vibrateToggleBtn.hidden = true
+    }
+
     settingsBtn.addEventListener('click', () => {
         settingsPanel.hidden = false
         refreshQR()
@@ -121,6 +130,13 @@ export function initUI(callbacks: UICallbacks): void {
         setTimeout(() => { testVibrateBtn.textContent = orig }, 1500)
     })
 
+    vibrateToggleBtn.addEventListener('click', () => {
+        const on = !isVibrateEnabled()
+        setVibrateEnabled(on)
+        vibrateToggleBtn.textContent = on ? 'Vibration: On' : 'Vibration: Off'
+        vibrateToggleBtn.classList.toggle('active', on)
+    })
+
     copyLinkBtn.addEventListener('click', () => {
         const url = callbacks.getShareURL()
         const orig = copyLinkBtn.textContent
diff --git a/src/vibration.ts b/src/vibration.ts
index 57bab60..734767e 100644
--- a/src/vibration.ts
+++ b/src/vibration.ts
@@ -4,12 +4,21 @@ import { dbg } from './debug'
 let lastVibrationAt = 0
 let flashFn: (() => void) | undefined
 let unlocked = false
+let vibrateEnabled = true
 
 /** Register a visual fallback called when the Vibration API is unavailable. */
 export function setFlashFallback(fn: () => void): void {
     flashFn = fn
 }
 
+export function setVibrateEnabled(on: boolean): void {
+    vibrateEnabled = on
+}
+
+export function isVibrateEnabled(): boolean {
+    return vibrateEnabled
+}
+
 /**
  * Call once from a user-gesture handler (e.g. the tap-to-start).
  * Chrome requires at least one vibrate() call from a gesture context before
@@ -56,6 +65,7 @@ export function vibrate(durationMs: number): void {
     // Chrome won't vibrate if the page is backgrounded or display is off
     if (document.visibilityState !== 'visible') return
     lastVibrationAt = now
+    if (!vibrateEnabled) return
     if (canVibrate()) {
         const ok = navigator.vibrate(durationMs)
         dbg('vibrate', durationMs, 'ms ->', ok)
@@ -70,6 +80,7 @@ export function vibrateShake(): void {
     const now = Date.now()
     if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return
     lastVibrationAt = now
+    if (!vibrateEnabled) return
     if (canVibrate()) {
         navigator.vibrate([25, 15, 25])
     } else {