{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-sound", "title": "Sound Hook", "description": "Custom React hook to load and play a sound from a given URL using the Web Audio API.", "files": [ { "path": "src/hooks/use-sound.ts", "content": "\"use client\"\r\n\r\nimport { useCallback, useEffect, useRef, useState } from \"react\"\r\n\r\n/**\r\n * Cache storage for loaded audio buffers to prevent duplicate network requests and memory usage.\r\n * Maps audio URL to its decoded AudioBuffer.\r\n */\r\nconst audioCache = new Map<\r\n string,\r\n {\r\n buffer: AudioBuffer\r\n loading: Promise\r\n } | null\r\n>()\r\n\r\n/**\r\n * Shared AudioContext instance to avoid creating multiple contexts.\r\n * Multiple AudioContexts can cause performance issues and resource exhaustion.\r\n */\r\nlet sharedAudioContext: AudioContext | null = null\r\n\r\n/**\r\n * Gets or creates a shared AudioContext instance.\r\n */\r\nfunction getAudioContext(): AudioContext | null {\r\n if (sharedAudioContext) return sharedAudioContext\r\n\r\n const AudioContextClass =\r\n window.AudioContext ||\r\n (window as unknown as { webkitAudioContext: typeof AudioContext })\r\n .webkitAudioContext\r\n\r\n if (!AudioContextClass) {\r\n console.warn(\"Web Audio API is not supported in this browser.\")\r\n return null\r\n }\r\n\r\n sharedAudioContext = new AudioContextClass()\r\n return sharedAudioContext\r\n}\r\n\r\n/**\r\n * Custom React hook to load and play a sound from a given URL using the Web Audio API.\r\n *\r\n * This hook implements caching to prevent duplicate network requests and memory usage\r\n * when the same audio file is used across multiple components.\r\n *\r\n * @param url - The URL of the audio file to load and play.\r\n * @returns A function that, when called, plays the loaded sound.\r\n *\r\n * @remarks\r\n * - Audio buffers are cached globally, so the same file is only loaded once\r\n * - Uses a shared AudioContext to avoid resource exhaustion\r\n * - If the Web Audio API is not supported in the browser, a warning is logged and playback is disabled\r\n * - Errors during fetching or decoding the audio are logged to the console\r\n *\r\n * @example\r\n * ```tsx\r\n * const playClick = useSound('/sounds/click.mp3');\r\n * // Later in an event handler:\r\n * playClick();\r\n * ```\r\n */\r\nexport function useSound(url: string) {\r\n const audioCtxRef = useRef(null)\r\n const bufferRef = useRef(null)\r\n\r\n useEffect(() => {\r\n const audioCtx = getAudioContext()\r\n if (!audioCtx) return\r\n\r\n audioCtxRef.current = audioCtx\r\n\r\n // Check if already cached\r\n const cached = audioCache.get(url)\r\n if (cached?.buffer) {\r\n bufferRef.current = cached.buffer\r\n return\r\n }\r\n\r\n // Check if already loading\r\n if (cached?.loading) {\r\n cached.loading\r\n .then((decoded) => {\r\n bufferRef.current = decoded\r\n })\r\n .catch(() => {\r\n // Error already logged during fetch\r\n })\r\n return\r\n }\r\n\r\n // Start loading\r\n const loadingPromise = fetch(url)\r\n .then((res) => res.arrayBuffer())\r\n .then((data) => audioCtx.decodeAudioData(data))\r\n .then((decoded) => {\r\n // Store in cache\r\n audioCache.set(url, { buffer: decoded, loading: loadingPromise })\r\n bufferRef.current = decoded\r\n return decoded\r\n })\r\n .catch((err) => {\r\n console.log(`Failed to load sound from ${url}:`, err)\r\n // Mark as failed in cache\r\n audioCache.set(url, null)\r\n throw err\r\n })\r\n\r\n // Mark as loading in cache\r\n audioCache.set(url, { buffer: null!, loading: loadingPromise })\r\n }, [url])\r\n\r\n const play = useCallback((volume: number = 1) => {\r\n if (audioCtxRef.current && bufferRef.current) {\r\n const source = audioCtxRef.current.createBufferSource()\r\n const gainNode = audioCtxRef.current.createGain()\r\n\r\n source.buffer = bufferRef.current\r\n gainNode.gain.value = volume\r\n\r\n source.connect(gainNode)\r\n gainNode.connect(audioCtxRef.current.destination)\r\n source.start(0)\r\n }\r\n }, [])\r\n\r\n return play\r\n}\r\n\r\n/**\r\n * Custom React hook for lazy loading and playing sounds with manual preload control.\r\n *\r\n * Unlike `useSound()`, this hook does NOT load audio on mount. Audio is only fetched when:\r\n * - `preload()` is manually called (e.g., on hover)\r\n * - `play()` is called and audio is not yet loaded (auto-load fallback)\r\n *\r\n * This is ideal for audio that may not be needed by most users, saving initial bandwidth and memory.\r\n *\r\n * @param url - The URL of the audio file to load and play.\r\n * @returns Object with play function, preload function, and loading states.\r\n *\r\n * @remarks\r\n * - Audio buffers are cached globally and shared with `useSound()`\r\n * - Uses a shared AudioContext to avoid resource exhaustion\r\n * - If the Web Audio API is not supported, warnings are logged and playback is disabled\r\n * - Errors during fetching or decoding are logged to the console\r\n *\r\n * @example\r\n * ```tsx\r\n * const { play, preload, isLoading, isLoaded } = useSoundLazy('/sounds/rare.mp3');\r\n *\r\n * // Preload on hover for instant playback on click\r\n * preload()}\r\n * onClick={() => play()}\r\n * >\r\n * Play Sound\r\n * \r\n * ```\r\n */\r\nexport function useSoundLazy(url: string) {\r\n const audioCtxRef = useRef(null)\r\n const bufferRef = useRef(null)\r\n const loadingPromiseRef = useRef | null>(null)\r\n const [isLoading, setIsLoading] = useState(false)\r\n const [isLoaded, setIsLoaded] = useState(() => {\r\n // Check if already cached on initial render\r\n const cached = audioCache.get(url)\r\n return !!cached?.buffer\r\n })\r\n\r\n useEffect(() => {\r\n // Initialize AudioContext reference\r\n const audioCtx = getAudioContext()\r\n if (audioCtx) {\r\n audioCtxRef.current = audioCtx\r\n }\r\n\r\n // Check if already cached (e.g., loaded by another component)\r\n const cached = audioCache.get(url)\r\n if (cached?.buffer) {\r\n bufferRef.current = cached.buffer\r\n }\r\n }, [url])\r\n\r\n const load = useCallback(() => {\r\n // Early return if already loaded\r\n if (bufferRef.current) {\r\n return Promise.resolve(bufferRef.current)\r\n }\r\n\r\n // Return existing loading promise if already loading\r\n if (loadingPromiseRef.current) {\r\n return loadingPromiseRef.current\r\n }\r\n\r\n const audioCtx = getAudioContext()\r\n if (!audioCtx) {\r\n return Promise.reject(new Error(\"Web Audio API not supported\"))\r\n }\r\n\r\n audioCtxRef.current = audioCtx\r\n\r\n // Check cache\r\n const cached = audioCache.get(url)\r\n if (cached?.buffer) {\r\n bufferRef.current = cached.buffer\r\n setIsLoaded(true)\r\n return Promise.resolve(cached.buffer)\r\n }\r\n\r\n // Check if already loading by another component\r\n if (cached?.loading) {\r\n setIsLoading(true)\r\n const promise = cached.loading\r\n .then((decoded) => {\r\n bufferRef.current = decoded\r\n setIsLoaded(true)\r\n return decoded\r\n })\r\n .catch((err) => {\r\n // Error already logged during fetch\r\n throw err\r\n })\r\n .finally(() => {\r\n setIsLoading(false)\r\n loadingPromiseRef.current = null\r\n })\r\n\r\n loadingPromiseRef.current = promise\r\n return promise\r\n }\r\n\r\n // Start new load\r\n setIsLoading(true)\r\n const loadingPromise = fetch(url)\r\n .then((res) => res.arrayBuffer())\r\n .then((data) => audioCtx.decodeAudioData(data))\r\n .then((decoded) => {\r\n audioCache.set(url, { buffer: decoded, loading: loadingPromise })\r\n bufferRef.current = decoded\r\n setIsLoaded(true)\r\n return decoded\r\n })\r\n .catch((err) => {\r\n console.log(`Failed to load sound from ${url}:`, err)\r\n audioCache.set(url, null)\r\n throw err\r\n })\r\n .finally(() => {\r\n setIsLoading(false)\r\n loadingPromiseRef.current = null\r\n })\r\n\r\n // Mark as loading in cache\r\n audioCache.set(url, { buffer: null!, loading: loadingPromise })\r\n loadingPromiseRef.current = loadingPromise\r\n return loadingPromise\r\n }, [url])\r\n\r\n const preload = useCallback(() => {\r\n load().catch(() => {\r\n // Error already logged in load()\r\n })\r\n }, [load])\r\n\r\n const play = useCallback(\r\n (volume: number = 1) => {\r\n const playSound = () => {\r\n if (audioCtxRef.current && bufferRef.current) {\r\n const source = audioCtxRef.current.createBufferSource()\r\n const gainNode = audioCtxRef.current.createGain()\r\n\r\n source.buffer = bufferRef.current\r\n gainNode.gain.value = volume\r\n\r\n source.connect(gainNode)\r\n gainNode.connect(audioCtxRef.current.destination)\r\n source.start(0)\r\n }\r\n }\r\n\r\n // If already loaded, play immediately\r\n if (bufferRef.current) {\r\n playSound()\r\n return\r\n }\r\n\r\n // Auto-load fallback: load then play\r\n load()\r\n .then(() => {\r\n playSound()\r\n })\r\n .catch(() => {\r\n // Error already logged in load()\r\n })\r\n },\r\n [load]\r\n )\r\n\r\n return { play, preload, isLoading, isLoaded }\r\n}\r\n", "type": "registry:hook" } ], "type": "registry:hook" }