/** * useAudioEffects Hook * * Manages audio playback for element hover and click effects. * Uses HTML5 Audio API for lightweight, non-visual audio playback. * * Behavior: * - Hover audio: Plays to completion once started (not interrupted by mouse leave) * - Click audio: Interruptible - clicking again restarts from beginning * * Cross-browser notes: * - iOS Safari: Volume property is read-only (always 1.0) * - Mobile browsers: Require user interaction before audio can play * - Touch on element counts as user gesture (unlocks audio context) */ import { useEffect, useRef, useCallback, useState } from 'react'; import { logger } from '../lib/logger'; /** * Fetch audio file with credentials and return a blob URL. * This handles authenticated URLs that require cookies/headers. */ async function fetchAudioAsBlobUrl(url: string): Promise { try { const response = await fetch(url, { credentials: 'include', // Include cookies for auth }); if (!response.ok) { logger.warn('[AudioEffects] Failed to fetch audio:', { status: response.status, }); return null; } const blob = await response.blob(); return URL.createObjectURL(blob); } catch (error) { logger.warn('[AudioEffects] Error fetching audio:', error); return null; } } export interface UseAudioEffectsOptions { /** URL of audio to play on hover */ hoverAudioUrl?: string; /** URL of audio to play on click/active */ clickAudioUrl?: string; /** Volume level 0-1 (iOS ignores this) */ volume?: number; /** Current hover state from useElementEffects */ isHovered: boolean; /** Current active/pressed state from useElementEffects */ isActive: boolean; /** Optional URL resolver for preloaded blob URLs */ resolveUrl?: (url: string | undefined) => string; /** Reset key to stop audio (e.g., element ID or page slug) */ resetKey?: string | number; } export interface UseAudioEffectsResult { /** Manually trigger click audio playback */ playClickAudio: () => void; /** Stop all audio playback */ stopAll: () => void; } /** * Hook for managing audio playback on element hover and click. */ export function useAudioEffects({ hoverAudioUrl, clickAudioUrl, volume = 1, isHovered, isActive, resolveUrl, resetKey, }: UseAudioEffectsOptions): UseAudioEffectsResult { const hoverAudioRef = useRef(null); const clickAudioRef = useRef(null); const hoverBlobUrlRef = useRef(null); const clickBlobUrlRef = useRef(null); const wasHoveredRef = useRef(false); const wasActiveRef = useRef(false); // Delay audio playback after mount/reset to avoid browser autoplay policy // User can't hover during mount, so any true state within 100ms is stale const [canPlay, setCanPlay] = useState(false); // Track which URLs we've already fetched to prevent duplicate fetches const lastFetchedHoverUrlRef = useRef(null); const lastFetchedClickUrlRef = useRef(null); // Store resolveUrl in ref to avoid dependency issues const resolveUrlRef = useRef(resolveUrl); resolveUrlRef.current = resolveUrl; // Track loading state const [hoverAudioReady, setHoverAudioReady] = useState(false); const [clickAudioReady, setClickAudioReady] = useState(false); // Enable audio playback after short delay to skip stale state at mount // Any hover/active state within 100ms of mount is not from real user interaction useEffect(() => { setCanPlay(false); wasHoveredRef.current = isHovered; wasActiveRef.current = isActive; const timer = setTimeout(() => setCanPlay(true), 100); return () => clearTimeout(timer); // Only reset on resetKey change, not on isHovered/isActive changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [resetKey]); // Initialize hover audio element - fetch with credentials for auth useEffect(() => { if (!hoverAudioUrl) { hoverAudioRef.current = null; setHoverAudioReady(false); lastFetchedHoverUrlRef.current = null; return; } // Skip if we already fetched this URL if ( lastFetchedHoverUrlRef.current === hoverAudioUrl && hoverAudioRef.current ) { return; } let cancelled = false; const resolve = resolveUrlRef.current; const resolvedUrl = resolve ? resolve(hoverAudioUrl) : hoverAudioUrl; logger.debug('[AudioEffects] Initializing hover audio:', { original: hoverAudioUrl, resolved: resolvedUrl, }); // Check if URL is already a blob URL (from preload cache) if (resolvedUrl.startsWith('blob:')) { const audio = new Audio(resolvedUrl); audio.volume = Math.max(0, Math.min(1, volume)); audio.preload = 'auto'; hoverAudioRef.current = audio; setHoverAudioReady(true); lastFetchedHoverUrlRef.current = hoverAudioUrl; logger.debug('[AudioEffects] Hover audio ready (blob URL)'); return; } // Fetch with credentials and create blob URL fetchAudioAsBlobUrl(resolvedUrl).then((blobUrl) => { if (cancelled || !blobUrl) return; // Clean up previous blob URL if any if (hoverBlobUrlRef.current) { URL.revokeObjectURL(hoverBlobUrlRef.current); } hoverBlobUrlRef.current = blobUrl; const audio = new Audio(blobUrl); audio.volume = Math.max(0, Math.min(1, volume)); audio.preload = 'auto'; hoverAudioRef.current = audio; setHoverAudioReady(true); lastFetchedHoverUrlRef.current = hoverAudioUrl; logger.debug('[AudioEffects] Hover audio ready (fetched)'); }); return () => { cancelled = true; }; }, [hoverAudioUrl, volume]); // Initialize click audio element - fetch with credentials for auth useEffect(() => { if (!clickAudioUrl) { clickAudioRef.current = null; setClickAudioReady(false); lastFetchedClickUrlRef.current = null; return; } // Skip if we already fetched this URL if ( lastFetchedClickUrlRef.current === clickAudioUrl && clickAudioRef.current ) { return; } let cancelled = false; const resolve = resolveUrlRef.current; const resolvedUrl = resolve ? resolve(clickAudioUrl) : clickAudioUrl; // Check if URL is already a blob URL (from preload cache) if (resolvedUrl.startsWith('blob:')) { const audio = new Audio(resolvedUrl); audio.volume = Math.max(0, Math.min(1, volume)); audio.preload = 'auto'; clickAudioRef.current = audio; setClickAudioReady(true); lastFetchedClickUrlRef.current = clickAudioUrl; return; } // Fetch with credentials and create blob URL fetchAudioAsBlobUrl(resolvedUrl).then((blobUrl) => { if (cancelled || !blobUrl) return; // Clean up previous blob URL if any if (clickBlobUrlRef.current) { URL.revokeObjectURL(clickBlobUrlRef.current); } clickBlobUrlRef.current = blobUrl; const audio = new Audio(blobUrl); audio.volume = Math.max(0, Math.min(1, volume)); audio.preload = 'auto'; clickAudioRef.current = audio; setClickAudioReady(true); lastFetchedClickUrlRef.current = clickAudioUrl; logger.debug('[AudioEffects] Click audio ready (fetched)'); }); return () => { cancelled = true; }; }, [clickAudioUrl, volume]); // Clean up blob URLs on unmount useEffect(() => { return () => { if (hoverBlobUrlRef.current) { URL.revokeObjectURL(hoverBlobUrlRef.current); } if (clickBlobUrlRef.current) { URL.revokeObjectURL(clickBlobUrlRef.current); } }; }, []); // Update volume on both audio elements when it changes useEffect(() => { const clampedVolume = Math.max(0, Math.min(1, volume)); if (hoverAudioRef.current) { hoverAudioRef.current.volume = clampedVolume; } if (clickAudioRef.current) { clickAudioRef.current.volume = clampedVolume; } }, [volume]); // Play hover audio when hover starts (plays to completion, not interrupted) useEffect(() => { // Only play if: canPlay is true, hover changed from false→true, audio is ready if ( canPlay && isHovered && !wasHoveredRef.current && hoverAudioRef.current && hoverAudioReady ) { logger.debug('[AudioEffects] Playing hover audio...'); hoverAudioRef.current.currentTime = 0; // Graceful autoplay handling - catch and ignore autoplay restrictions // Audio will work after user's first click/tap on any element hoverAudioRef.current.play().catch((err) => { logger.warn('[AudioEffects] Play failed:', err); }); } wasHoveredRef.current = isHovered; }, [canPlay, isHovered, hoverAudioReady]); // Play click audio when active state begins useEffect(() => { // Only play if: canPlay is true, active changed from false→true, audio is ready if ( canPlay && isActive && !wasActiveRef.current && clickAudioRef.current && clickAudioReady ) { clickAudioRef.current.currentTime = 0; // Click implies user interaction, so this should rarely fail clickAudioRef.current.play().catch(() => { // Silent fail for edge cases }); } wasActiveRef.current = isActive; }, [canPlay, isActive, clickAudioReady]); // Manual click audio trigger const playClickAudio = useCallback(() => { if (clickAudioRef.current && clickAudioReady) { clickAudioRef.current.currentTime = 0; // eslint-disable-next-line @typescript-eslint/no-empty-function clickAudioRef.current.play().catch(() => {}); } }, [clickAudioReady]); // Stop all audio playback const stopAll = useCallback(() => { if (hoverAudioRef.current) { hoverAudioRef.current.pause(); hoverAudioRef.current.currentTime = 0; } if (clickAudioRef.current) { clickAudioRef.current.pause(); clickAudioRef.current.currentTime = 0; } }, []); // Stop audio on key change (e.g., page navigation or element change) // Note: canPlay and refs are reset in the canPlay effect above useEffect(() => { stopAll(); }, [resetKey, stopAll]); return { playClickAudio, stopAll }; }