diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index eca7008..29c1727 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -34,9 +34,9 @@ export default function Home() { const bumpAudio = useShakeAudio(); const requestMotionPermission = async () => { - if (typeof window === 'undefined') return; + if (globalThis.window === undefined) return; - if (!('DeviceMotionEvent' in window)) { + if (!('DeviceMotionEvent' in globalThis)) { setMotionPermission('denied'); return; } @@ -57,7 +57,15 @@ export default function Home() { const triggerShake = useCallback((intensity: number) => { bumpAudio(); - if (!isAnimatingRef.current) { + if (isAnimatingRef.current) { + const timeSinceStart = Date.now() - animationStartTimeRef.current; + if (timeSinceStart > 100) { + setShakeQueue(prev => { + if (prev.length >= 1) return prev; + return [...prev, intensity]; + }); + } + } else { if (animationTimeoutRef.current) { clearTimeout(animationTimeoutRef.current); } @@ -86,14 +94,6 @@ export default function Home() { return prev; }); }, shakeConfig.animations.shakeReset); - } else { - const timeSinceStart = Date.now() - animationStartTimeRef.current; - if (timeSinceStart > 100) { - setShakeQueue(prev => { - if (prev.length >= 1) return prev; - return [...prev, intensity]; - }); - } } }, [bumpAudio]); @@ -108,7 +108,7 @@ export default function Home() { const acceleration = event.accelerationIncludingGravity; if (!acceleration) return; - const currentTime = new Date().getTime(); + const currentTime = Date.now(); const timeDiff = currentTime - lastUpdate; if (timeDiff > shakeConfig.debounceTime) { @@ -124,19 +124,19 @@ export default function Home() { } }; - if (typeof window !== 'undefined') { - if (motionPermission === 'granted' && 'DeviceMotionEvent' in window) { - window.addEventListener('devicemotion', handleMotion); + if (globalThis.window !== undefined) { + if (motionPermission === 'granted' && 'DeviceMotionEvent' in globalThis) { + globalThis.addEventListener('devicemotion', handleMotion); } - window.addEventListener('keydown', handleKeyPress); + globalThis.addEventListener('keydown', handleKeyPress); } return () => { - if (typeof window !== 'undefined') { + if (globalThis.window !== undefined) { if (motionPermission === 'granted') { - window.removeEventListener('devicemotion', handleMotion); + globalThis.removeEventListener('devicemotion', handleMotion); } - window.removeEventListener('keydown', handleKeyPress); + globalThis.removeEventListener('keydown', handleKeyPress); } }; }, [lastUpdate, motionPermission, triggerShake]); diff --git a/app/hooks/useShakeAudio.ts b/app/hooks/useShakeAudio.ts index a584e2b..be69f10 100644 --- a/app/hooks/useShakeAudio.ts +++ b/app/hooks/useShakeAudio.ts @@ -5,7 +5,7 @@ import { useRef, useEffect, useCallback } from 'react'; const AUDIO_URL = '/audio/starley_call_on_me.ogg'; const FADE_IN_SEC = 0.1; const FADE_OUT_SEC = 0.8; -const QUIET_TIMEOUT_MS = 300; +const QUIET_TIMEOUT_MS = 1000; const PLAY_GAIN = 0.7; // exponentialRampToValueAtTime can't reach 0; use a tiny positive target const NEAR_ZERO = 0.0001; @@ -16,6 +16,7 @@ export function useShakeAudio() { const sourceRef = useRef(null); const gainRef = useRef(null); const quietTimerRef = useRef | null>(null); + const stopTimeoutRef = useRef | null>(null); const fadeOutAndStop = useCallback(() => { const ctx = ctxRef.current; @@ -28,18 +29,22 @@ export function useShakeAudio() { gain.gain.setValueAtTime(gain.gain.value, now); gain.gain.linearRampToValueAtTime(NEAR_ZERO, now + FADE_OUT_SEC); - try { - source.stop(now + FADE_OUT_SEC + 0.05); - } catch { - // source may already be stopped - } - - sourceRef.current = null; - gainRef.current = null; + if (stopTimeoutRef.current) clearTimeout(stopTimeoutRef.current); + // Defer source.stop() via setTimeout (not source.stop(time)) so a bump + // arriving mid-fade can cancel it and ramp back up without restarting. + stopTimeoutRef.current = setTimeout(() => { + if (sourceRef.current === source) { + try { source.stop(); } catch { /* already stopped */ } + source.disconnect(); + sourceRef.current = null; + gainRef.current = null; + } + stopTimeoutRef.current = null; + }, FADE_OUT_SEC * 1000 + 50); }, []); const bump = useCallback(async () => { - if (typeof window === 'undefined') return; + if (globalThis.window === undefined) return; if (!ctxRef.current) { try { @@ -74,7 +79,20 @@ export function useShakeAudio() { if (quietTimerRef.current) clearTimeout(quietTimerRef.current); quietTimerRef.current = setTimeout(fadeOutAndStop, QUIET_TIMEOUT_MS); - if (sourceRef.current) return; + if (sourceRef.current && gainRef.current) { + // Already playing or mid-fade-out: cancel any scheduled stop and + // ramp gain back up to PLAY_GAIN. Loop position is preserved. + if (stopTimeoutRef.current) { + clearTimeout(stopTimeoutRef.current); + stopTimeoutRef.current = null; + } + const now = ctx.currentTime; + const gainParam = gainRef.current.gain; + gainParam.cancelScheduledValues(now); + gainParam.setValueAtTime(gainParam.value, now); + gainParam.linearRampToValueAtTime(PLAY_GAIN, now + FADE_IN_SEC); + return; + } const source = ctx.createBufferSource(); source.buffer = bufferRef.current; @@ -95,6 +113,7 @@ export function useShakeAudio() { useEffect(() => { return () => { if (quietTimerRef.current) clearTimeout(quietTimerRef.current); + if (stopTimeoutRef.current) clearTimeout(stopTimeoutRef.current); const source = sourceRef.current; if (source) { try { source.stop(); } catch { /* already stopped */ }