{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-sound", "title": "useSound", "author": "tusharvarshney ", "description": "Hook for playing sound effects with volume, playback rate, and interrupt controls.", "files": [ { "path": "src/registry/hooks/sound/use-sound.ts", "content": "\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\n\nimport {\n fetchAndDecodeAudio,\n getAudioContext,\n} from \"@/registry/lib/sound/sound-engine\"\nimport type {\n PlayFunction,\n SoundControls,\n UseSoundOptions,\n UseSoundReturn,\n} from \"@/registry/lib/sound/sound-types\"\n\nexport function useSound(\n url: string,\n options: UseSoundOptions = {}\n): UseSoundReturn {\n const {\n volume = 1,\n playbackRate = 1,\n interrupt = false,\n soundEnabled = true,\n lazy = false,\n onPlay,\n onEnd,\n onPause,\n onStop,\n } = options\n\n const [isPlaying, setIsPlaying] = useState(false)\n\n const sourceRef = useRef(null)\n const gainRef = useRef(null)\n const bufferRef = useRef(null)\n\n useEffect(() => {\n bufferRef.current = null\n\n if (lazy) return\n\n let cancelled = false\n\n fetchAndDecodeAudio(url).then((buffer) => {\n if (cancelled) return\n bufferRef.current = buffer\n })\n\n return () => {\n cancelled = true\n }\n }, [url, lazy])\n\n const stop = useCallback(() => {\n if (sourceRef.current) {\n try {\n sourceRef.current.stop()\n } catch {\n // Already stopped.\n }\n sourceRef.current = null\n }\n setIsPlaying(false)\n onStop?.()\n }, [onStop])\n\n const play: PlayFunction = useCallback(\n (overrides?) => {\n if (!soundEnabled) return\n\n const startPlayback = (buffer: AudioBuffer) => {\n const ctx = getAudioContext()\n\n if (ctx.state === \"suspended\") {\n ctx.resume()\n }\n\n if (interrupt && sourceRef.current) {\n stop()\n }\n\n const source = ctx.createBufferSource()\n const gain = ctx.createGain()\n\n source.buffer = buffer\n source.playbackRate.value = overrides?.playbackRate ?? playbackRate\n gain.gain.value = overrides?.volume ?? volume\n\n source.connect(gain)\n gain.connect(ctx.destination)\n\n source.onended = () => {\n setIsPlaying(false)\n onEnd?.()\n }\n\n source.start(0)\n sourceRef.current = source\n gainRef.current = gain\n setIsPlaying(true)\n onPlay?.()\n }\n\n if (bufferRef.current) {\n startPlayback(bufferRef.current)\n return\n }\n\n // Lazy: load on first play, then play immediately.\n fetchAndDecodeAudio(url).then((buffer) => {\n bufferRef.current = buffer\n startPlayback(buffer)\n })\n },\n [soundEnabled, url, interrupt, playbackRate, volume, stop, onPlay, onEnd]\n )\n\n const pause = useCallback(() => {\n stop()\n onPause?.()\n }, [stop, onPause])\n\n // Keep gain in sync with volume changes without restarting playback.\n useEffect(() => {\n if (gainRef.current) {\n gainRef.current.gain.value = volume\n }\n }, [volume])\n\n // Cleanup on unmount.\n useEffect(() => {\n return () => {\n if (sourceRef.current) {\n try {\n sourceRef.current.stop()\n } catch {\n // Already stopped.\n }\n }\n }\n }, [])\n\n const controls: SoundControls = { stop, pause, isPlaying }\n\n return [play, controls] as const\n}\n", "type": "registry:hook", "target": "@hooks/sound/use-sound.ts" }, { "path": "src/registry/lib/sound/sound-engine.ts", "content": "let audioContext: AudioContext | null = null\n\n// Cache the Promise itself to deduplicate concurrent fetch calls for the same URL.\nconst bufferCache = new Map>()\n\nexport function getAudioContext(): AudioContext {\n if (!audioContext) {\n audioContext = new AudioContext()\n }\n return audioContext\n}\n\nexport function fetchAndDecodeAudio(url: string): Promise {\n const cached = bufferCache.get(url)\n if (cached) return cached\n\n const ctx = getAudioContext()\n\n const promise = fetch(url)\n .then((res) => {\n if (!res.ok) throw new Error(`Failed to fetch audio: ${res.status}`)\n return res.arrayBuffer()\n })\n .then((data) => ctx.decodeAudioData(data))\n .catch((err) => {\n // Remove failed entry so subsequent calls can retry.\n bufferCache.delete(url)\n throw err\n })\n\n bufferCache.set(url, promise)\n return promise\n}\n", "type": "registry:lib", "target": "@lib/sound/sound-engine.ts" }, { "path": "src/registry/lib/sound/sound-types.ts", "content": "export interface UseSoundOptions {\n /** Volume level from 0 to 1. Default: 1 */\n volume?: number\n /** Playback speed multiplier. Default: 1 */\n playbackRate?: number\n /** If true, calling play() stops current playback first. Default: false */\n interrupt?: boolean\n /** If false, play() does nothing. Useful for user preferences. Default: true */\n soundEnabled?: boolean\n /**\n * When true, audio is not fetched until the first play() call.\n * When false, audio is fetched immediately on mount.\n * Default: false\n */\n lazy?: boolean\n /** Called when playback starts */\n onPlay?: () => void\n /** Called when playback ends naturally */\n onEnd?: () => void\n /** Called when pause() is called */\n onPause?: () => void\n /** Called when stop() is called */\n onStop?: () => void\n}\n\nexport type PlayFunction = (overrides?: {\n volume?: number\n playbackRate?: number\n}) => void\n\nexport interface SoundControls {\n stop: () => void\n pause: () => void\n isPlaying: boolean\n}\n\nexport type UseSoundReturn = readonly [PlayFunction, SoundControls]\n", "type": "registry:lib", "target": "@lib/sound/sound-types.ts" } ], "type": "registry:hook" }