{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "speaker-01", "description": "EL-01 Speaker", "registryDependencies": [ "https://ui.elevenlabs.io/r/audio-player.json", "https://ui.elevenlabs.io/r/orb.json", "https://ui.elevenlabs.io/r/waveform.json", "button", "card" ], "files": [ { "path": "blocks/speaker-01/page.tsx", "content": "import { Speaker } from \"@/components/speaker\"\n\nexport default function Page() {\n return \n}\n", "type": "registry:page", "target": "app/speaker/page.tsx" }, { "path": "blocks/speaker-01/components/speaker.tsx", "content": "\"use client\"\n\nimport { memo, useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport Link from \"next/link\"\nimport {\n Music,\n SkipBack,\n SkipForward,\n Sparkles,\n Volume,\n Volume1,\n Volume2,\n VolumeX,\n} from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n AudioPlayerButton,\n AudioPlayerDuration,\n AudioPlayerProgress,\n AudioPlayerProvider,\n AudioPlayerTime,\n exampleTracks,\n useAudioPlayer,\n} from \"@/components/ui/audio-player\"\nimport { Button } from \"@/components/ui/button\"\nimport { Card } from \"@/components/ui/card\"\nimport { Orb } from \"@/components/ui/orb\"\nimport { Waveform } from \"@/components/ui/waveform\"\n\nconst globalAudioState = {\n isPlaying: false,\n volume: 0.7,\n isDark: false,\n}\n\nconst PlayButton = memo(\n ({ currentTrackIndex }: { currentTrackIndex: number }) => {\n const player = useAudioPlayer()\n return (\n \n )\n }\n)\n\nPlayButton.displayName = \"PlayButton\"\n\nconst TimeDisplay = memo(() => {\n return (\n
\n \n \n \n
\n )\n})\n\nTimeDisplay.displayName = \"TimeDisplay\"\n\nconst SpeakerContextBridge = ({ className }: { className?: string }) => {\n const player = useAudioPlayer()\n const playerRefStatic = useRef(player)\n\n playerRefStatic.current = player\n\n return useMemo(\n () => ,\n [className]\n )\n}\n\nexport function Speaker({ className }: { className?: string }) {\n return (\n \n \n \n )\n}\n\nconst SpeakerOrb = memo(\n ({\n seed,\n side,\n isDark,\n audioDataRef,\n }: {\n seed: number\n side: \"left\" | \"right\"\n isDark: boolean\n audioDataRef: React.RefObject\n }) => {\n const getInputVolume = useCallback(() => {\n const audioData = audioDataRef?.current || []\n if (\n !globalAudioState.isPlaying ||\n globalAudioState.volume === 0 ||\n audioData.length === 0\n )\n return 0\n const lowFreqEnd = Math.floor(audioData.length * 0.25)\n let sum = 0\n for (let i = 0; i < lowFreqEnd; i++) {\n sum += audioData[i]\n }\n const avgLow = sum / lowFreqEnd\n const amplified = Math.pow(avgLow, 0.5) * 3.5\n return Math.max(0.2, Math.min(1.0, amplified))\n }, [audioDataRef])\n\n const getOutputVolume = useCallback(() => {\n const audioData = audioDataRef?.current || []\n if (\n !globalAudioState.isPlaying ||\n globalAudioState.volume === 0 ||\n audioData.length === 0\n )\n return 0\n const midStart = Math.floor(audioData.length * 0.25)\n const midEnd = Math.floor(audioData.length * 0.75)\n let sum = 0\n for (let i = midStart; i < midEnd; i++) {\n sum += audioData[i]\n }\n const avgMid = sum / (midEnd - midStart)\n const modifier = side === \"left\" ? 0.9 : 1.1\n const amplified = Math.pow(avgMid, 0.5) * 4.0\n return Math.max(0.25, Math.min(1.0, amplified * modifier))\n }, [side, audioDataRef])\n\n const colors: [string, string] = useMemo(\n () => (isDark ? [\"#A0A0A0\", \"#232323\"] : [\"#F4F4F4\", \"#E0E0E0\"]),\n [isDark]\n )\n\n return (\n \n )\n },\n (prevProps, nextProps) => {\n return (\n prevProps.isDark === nextProps.isDark &&\n prevProps.seed === nextProps.seed &&\n prevProps.side === nextProps.side\n )\n }\n)\n\nSpeakerOrb.displayName = \"SpeakerOrb\"\n\nconst SpeakerOrbsSection = memo(\n ({\n isDark,\n audioDataRef,\n }: {\n isDark: boolean\n audioDataRef: React.RefObject\n }) => {\n return (\n
\n
\n
\n
\n \n
\n
\n
\n\n
\n
\n
\n \n
\n
\n
\n
\n )\n },\n (prevProps, nextProps) => {\n return prevProps.isDark === nextProps.isDark\n }\n)\n\nSpeakerOrbsSection.displayName = \"SpeakerOrbsSection\"\n\nconst VolumeSlider = memo(\n ({\n volume,\n setVolume,\n }: {\n volume: number\n setVolume: (value: number | ((prev: number) => number)) => void\n }) => {\n const [isDragging, setIsDragging] = useState(false)\n\n const getVolumeIcon = () => {\n if (volume === 0) return VolumeX\n if (volume <= 0.33) return Volume\n if (volume <= 0.66) return Volume1\n return Volume2\n }\n\n const VolumeIcon = getVolumeIcon()\n\n return (\n
\n setVolume((prev: number) => (prev > 0 ? 0 : 0.7))}\n className=\"text-muted-foreground hover:text-foreground transition-colors\"\n >\n \n \n {\n if (isDragging) return\n const rect = e.currentTarget.getBoundingClientRect()\n const x = Math.max(\n 0,\n Math.min(1, (e.clientX - rect.left) / rect.width)\n )\n setVolume(x)\n }}\n onMouseDown={(e) => {\n e.preventDefault()\n setIsDragging(true)\n const sliderRect = e.currentTarget.getBoundingClientRect()\n\n // Set initial volume immediately\n const initialX = Math.max(\n 0,\n Math.min(1, (e.clientX - sliderRect.left) / sliderRect.width)\n )\n setVolume(initialX)\n\n const handleMove = (e: MouseEvent) => {\n const x = Math.max(\n 0,\n Math.min(1, (e.clientX - sliderRect.left) / sliderRect.width)\n )\n setVolume(x)\n }\n const handleUp = () => {\n setIsDragging(false)\n document.removeEventListener(\"mousemove\", handleMove)\n document.removeEventListener(\"mouseup\", handleUp)\n }\n document.addEventListener(\"mousemove\", handleMove)\n document.addEventListener(\"mouseup\", handleUp)\n }}\n >\n \n
\n \n {Math.round(volume * 100)}%\n \n \n )\n }\n)\n\nVolumeSlider.displayName = \"VolumeSlider\"\n\nfunction SpeakerControls({\n className,\n playerRef,\n}: {\n className?: string\n playerRef: React.RefObject>\n}) {\n const playerApiRef = playerRef\n const isPlayingRef = useRef(false)\n\n const [volume, setVolume] = useState(0.7)\n const [currentTrackIndex, setCurrentTrackIndex] = useState(0)\n const [showTrackList, setShowTrackList] = useState(false)\n const audioDataRef = useRef([])\n const [isDark, setIsDark] = useState(false)\n const [isScrubbing, setIsScrubbing] = useState(false)\n const [isMomentumActive, setIsMomentumActive] = useState(false)\n const [precomputedWaveform, setPrecomputedWaveform] = useState([])\n const waveformOffset = useRef(0)\n const waveformElementRef = useRef(null)\n const [ambienceMode, setAmbienceMode] = useState(false)\n const containerWidthRef = useRef(300)\n const analyserRef = useRef(null)\n const audioContextRef = useRef(null)\n const sourceRef = useRef(null)\n const audioBufferRef = useRef(null)\n const scratchBufferRef = useRef(null)\n const totalBarsRef = useRef(600)\n const convolverRef = useRef(null)\n const delayRef = useRef(null)\n const feedbackRef = useRef(null)\n const wetGainRef = useRef(null)\n const dryGainRef = useRef(null)\n const masterGainRef = useRef(null)\n const lowPassFilterRef = useRef(null)\n const highPassFilterRef = useRef(null)\n\n useEffect(() => {\n const checkTheme = () => {\n const isDarkMode = document.documentElement.classList.contains(\"dark\")\n setIsDark(isDarkMode)\n }\n\n checkTheme()\n\n const observer = new MutationObserver(checkTheme)\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\"],\n })\n\n return () => observer.disconnect()\n }, [])\n\n useEffect(() => {\n const container = document.querySelector(\".waveform-container\")\n if (container) {\n const rect = container.getBoundingClientRect()\n containerWidthRef.current = rect.width\n waveformOffset.current = rect.width\n if (waveformElementRef.current) {\n waveformElementRef.current.style.transform = `translateX(${rect.width}px)`\n }\n }\n }, [])\n\n useEffect(() => {\n if (precomputedWaveform.length > 0 && containerWidthRef.current > 0) {\n waveformOffset.current = containerWidthRef.current\n if (waveformElementRef.current) {\n waveformElementRef.current.style.transform = `translateX(${containerWidthRef.current}px)`\n }\n if (playerApiRef.current.ref.current) {\n playerApiRef.current.ref.current.currentTime = 0\n }\n }\n }, [precomputedWaveform])\n\n const precomputeWaveform = useCallback(async (audioUrl: string) => {\n try {\n const response = await fetch(audioUrl)\n const arrayBuffer = await response.arrayBuffer()\n\n const offlineContext = new OfflineAudioContext(1, 44100 * 5, 44100)\n const audioBuffer = await offlineContext.decodeAudioData(\n arrayBuffer.slice(0)\n )\n\n if (!audioContextRef.current) {\n const audioContext = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)()\n audioContextRef.current = audioContext\n }\n\n audioBufferRef.current =\n await audioContextRef.current.decodeAudioData(arrayBuffer)\n\n const channelData = audioBuffer.getChannelData(0)\n const samplesPerBar = Math.floor(\n channelData.length / totalBarsRef.current\n )\n const waveformData: number[] = []\n\n for (let i = 0; i < totalBarsRef.current; i++) {\n const start = i * samplesPerBar\n const end = start + samplesPerBar\n let sum = 0\n let count = 0\n\n for (let j = start; j < end && j < channelData.length; j += 100) {\n sum += Math.abs(channelData[j])\n count++\n }\n\n const average = count > 0 ? sum / count : 0\n waveformData.push(Math.min(1, average * 3))\n }\n\n setPrecomputedWaveform(waveformData)\n } catch (error) {\n console.error(\"Error precomputing waveform:\", error)\n }\n }, [])\n\n useEffect(() => {\n const track = {\n id: exampleTracks[0].id,\n src: exampleTracks[0].url,\n data: { name: exampleTracks[0].name },\n }\n playerApiRef.current.setActiveItem(track)\n precomputeWaveform(track.src)\n }, [precomputeWaveform])\n\n const createImpulseResponse = (\n audioContext: AudioContext,\n duration: number,\n decay: number\n ) => {\n const sampleRate = audioContext.sampleRate\n const length = sampleRate * duration\n const impulse = audioContext.createBuffer(2, length, sampleRate)\n\n for (let channel = 0; channel < 2; channel++) {\n const channelData = impulse.getChannelData(channel)\n for (let i = 0; i < length; i++) {\n const envelope = Math.pow(1 - i / length, decay)\n const earlyReflections = i < length * 0.1 ? Math.random() * 0.5 : 0\n const diffusion = (Math.random() * 2 - 1) * envelope\n const stereoWidth = channel === 0 ? 0.9 : 1.1\n\n channelData[i] = (diffusion + earlyReflections) * stereoWidth * 0.8\n }\n }\n return impulse\n }\n\n const setupAudioContext = useCallback((ambience: boolean) => {\n if (!playerApiRef.current.ref.current) {\n return\n }\n\n if (\n audioContextRef.current &&\n sourceRef.current &&\n wetGainRef.current &&\n dryGainRef.current\n ) {\n return\n }\n\n try {\n let audioContext = audioContextRef.current\n let source = sourceRef.current\n let analyser = analyserRef.current\n\n if (!audioContext) {\n audioContext = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)()\n audioContextRef.current = audioContext\n }\n\n if (audioContext.state === \"suspended\") {\n audioContext.resume()\n }\n\n if (!source) {\n source = audioContext.createMediaElementSource(\n playerApiRef.current.ref.current\n )\n sourceRef.current = source\n }\n\n if (!analyser) {\n analyser = audioContext.createAnalyser()\n analyser.fftSize = 512\n analyser.smoothingTimeConstant = 0.7\n analyserRef.current = analyser\n }\n\n const convolver = audioContext.createConvolver()\n convolver.buffer = createImpulseResponse(audioContext, 6, 1.5)\n\n const delay = audioContext.createDelay(2)\n delay.delayTime.value = 0.001\n\n const feedback = audioContext.createGain()\n feedback.gain.value = 0.05\n\n const lowPassFilter = audioContext.createBiquadFilter()\n lowPassFilter.type = \"lowpass\"\n lowPassFilter.frequency.value = 1500\n lowPassFilter.Q.value = 0.5\n\n const highPassFilter = audioContext.createBiquadFilter()\n highPassFilter.type = \"highpass\"\n highPassFilter.frequency.value = 100\n highPassFilter.Q.value = 0.7\n\n const wetGain = audioContext.createGain()\n wetGain.gain.value = ambience ? 0.85 : 0\n\n const dryGain = audioContext.createGain()\n dryGain.gain.value = ambience ? 0.4 : 1\n\n const masterGain = audioContext.createGain()\n masterGain.gain.value = 1\n\n const compressor = audioContext.createDynamicsCompressor()\n compressor.threshold.value = -12\n compressor.knee.value = 2\n compressor.ratio.value = 8\n compressor.attack.value = 0.003\n compressor.release.value = 0.1\n\n try {\n source.disconnect()\n if (analyserRef.current) analyserRef.current.disconnect()\n } catch (e) {}\n\n source.connect(dryGain)\n dryGain.connect(masterGain)\n\n source.connect(highPassFilter)\n highPassFilter.connect(convolver)\n convolver.connect(delay)\n\n delay.connect(feedback)\n feedback.connect(lowPassFilter)\n lowPassFilter.connect(delay)\n\n delay.connect(wetGain)\n wetGain.connect(masterGain)\n\n masterGain.connect(compressor)\n compressor.connect(analyser)\n analyser.connect(audioContext.destination)\n\n convolverRef.current = convolver\n delayRef.current = delay\n feedbackRef.current = feedback\n wetGainRef.current = wetGain\n dryGainRef.current = dryGain\n masterGainRef.current = masterGain\n lowPassFilterRef.current = lowPassFilter\n highPassFilterRef.current = highPassFilter\n } catch (error) {\n console.error(\"Error setting up audio context:\", error)\n }\n }, [])\n\n useEffect(() => {\n const handlePlay = () => {\n isPlayingRef.current = true\n globalAudioState.isPlaying = true\n\n if (!analyserRef.current) {\n setTimeout(() => {\n setupAudioContext(ambienceMode)\n }, 100)\n }\n }\n const handlePause = () => {\n isPlayingRef.current = false\n globalAudioState.isPlaying = false\n }\n\n const checkInterval = setInterval(() => {\n const audioEl = playerApiRef.current.ref.current\n if (audioEl) {\n clearInterval(checkInterval)\n\n audioEl.addEventListener(\"play\", handlePlay)\n audioEl.addEventListener(\"pause\", handlePause)\n audioEl.addEventListener(\"ended\", handlePause)\n\n if (!audioEl.paused) {\n handlePlay()\n }\n }\n }, 100)\n\n return () => {\n clearInterval(checkInterval)\n const audioEl = playerApiRef.current.ref.current\n if (audioEl) {\n audioEl.removeEventListener(\"play\", handlePlay)\n audioEl.removeEventListener(\"pause\", handlePause)\n audioEl.removeEventListener(\"ended\", handlePause)\n }\n }\n }, [ambienceMode, setupAudioContext])\n\n useEffect(() => {\n globalAudioState.isDark = isDark\n }, [isDark])\n\n useEffect(() => {\n if (playerApiRef.current.ref.current) {\n playerApiRef.current.ref.current.volume = volume\n }\n globalAudioState.volume = volume\n }, [volume])\n\n useEffect(() => {\n if (!audioContextRef.current) {\n return\n }\n\n const targetWet = ambienceMode ? 0.7 : 0\n const targetDry = ambienceMode ? 0.5 : 1\n const currentTime = audioContextRef.current.currentTime\n\n if (wetGainRef.current && dryGainRef.current) {\n wetGainRef.current.gain.cancelScheduledValues(currentTime)\n dryGainRef.current.gain.cancelScheduledValues(currentTime)\n\n wetGainRef.current.gain.setValueAtTime(\n wetGainRef.current.gain.value,\n currentTime\n )\n dryGainRef.current.gain.setValueAtTime(\n dryGainRef.current.gain.value,\n currentTime\n )\n\n wetGainRef.current.gain.linearRampToValueAtTime(\n targetWet,\n currentTime + 0.5\n )\n dryGainRef.current.gain.linearRampToValueAtTime(\n targetDry,\n currentTime + 0.5\n )\n }\n\n if (feedbackRef.current) {\n feedbackRef.current.gain.cancelScheduledValues(currentTime)\n feedbackRef.current.gain.setValueAtTime(\n feedbackRef.current.gain.value,\n currentTime\n )\n feedbackRef.current.gain.linearRampToValueAtTime(\n ambienceMode ? 0.25 : 0.05,\n currentTime + 0.5\n )\n }\n\n if (delayRef.current) {\n delayRef.current.delayTime.cancelScheduledValues(currentTime)\n delayRef.current.delayTime.setValueAtTime(\n delayRef.current.delayTime.value,\n currentTime\n )\n delayRef.current.delayTime.linearRampToValueAtTime(\n ambienceMode ? 0.25 : 0.001,\n currentTime + 0.5\n )\n }\n\n if (lowPassFilterRef.current) {\n lowPassFilterRef.current.frequency.cancelScheduledValues(currentTime)\n lowPassFilterRef.current.frequency.setValueAtTime(\n lowPassFilterRef.current.frequency.value,\n currentTime\n )\n lowPassFilterRef.current.frequency.linearRampToValueAtTime(\n ambienceMode ? 800 : 1500,\n currentTime + 0.5\n )\n lowPassFilterRef.current.Q.linearRampToValueAtTime(\n ambienceMode ? 0.7 : 0.5,\n currentTime + 0.5\n )\n }\n\n if (highPassFilterRef.current) {\n highPassFilterRef.current.frequency.cancelScheduledValues(currentTime)\n highPassFilterRef.current.frequency.setValueAtTime(\n highPassFilterRef.current.frequency.value,\n currentTime\n )\n highPassFilterRef.current.frequency.linearRampToValueAtTime(\n ambienceMode ? 200 : 100,\n currentTime + 0.5\n )\n }\n\n if (masterGainRef.current) {\n masterGainRef.current.gain.cancelScheduledValues(currentTime)\n masterGainRef.current.gain.setValueAtTime(\n masterGainRef.current.gain.value,\n currentTime\n )\n masterGainRef.current.gain.linearRampToValueAtTime(\n ambienceMode ? 1.2 : 1,\n currentTime + 0.5\n )\n }\n }, [ambienceMode])\n\n useEffect(() => {\n if (!isScrubbing && !isMomentumActive && playerApiRef.current.ref.current) {\n let animationId: number\n\n const updatePosition = () => {\n const audioEl = playerApiRef.current.ref.current\n if (\n audioEl &&\n !isScrubbing &&\n !isMomentumActive &&\n waveformElementRef.current\n ) {\n const duration = audioEl.duration\n const currentTime = audioEl.currentTime\n if (!isNaN(duration) && duration > 0) {\n const position = currentTime / duration\n const containerWidth = containerWidthRef.current\n const totalWidth = totalBarsRef.current * 5\n const newOffset = containerWidth - position * totalWidth\n waveformOffset.current = newOffset\n waveformElementRef.current.style.transform = `translateX(${newOffset}px)`\n }\n }\n animationId = requestAnimationFrame(updatePosition)\n }\n\n animationId = requestAnimationFrame(updatePosition)\n return () => cancelAnimationFrame(animationId)\n }\n }, [isScrubbing, isMomentumActive])\n\n useEffect(() => {\n let animationId: number\n\n const updateWaveform = () => {\n if (analyserRef.current && isPlayingRef.current) {\n const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount)\n analyserRef.current.getByteFrequencyData(dataArray)\n\n const normalizedData = Array.from(dataArray).map((value) => {\n const normalized = value / 255\n return normalized\n })\n\n audioDataRef.current = normalizedData\n } else if (!isPlayingRef.current && audioDataRef.current.length > 0) {\n audioDataRef.current = audioDataRef.current.map((v) => v * 0.9)\n }\n\n animationId = requestAnimationFrame(updateWaveform)\n }\n\n animationId = requestAnimationFrame(updateWaveform)\n\n return () => {\n if (animationId) {\n cancelAnimationFrame(animationId)\n }\n }\n }, [])\n\n const playTrack = useCallback(\n (index: number) => {\n setCurrentTrackIndex(index)\n const track = {\n id: exampleTracks[index].id,\n src: exampleTracks[index].url,\n data: { name: exampleTracks[index].name },\n }\n playerApiRef.current.play(track)\n setShowTrackList(false)\n precomputeWaveform(track.src)\n },\n [precomputeWaveform]\n )\n\n const nextTrack = () => {\n const nextIndex = (currentTrackIndex + 1) % exampleTracks.length\n playTrack(nextIndex)\n }\n\n const prevTrack = () => {\n const prevIndex =\n (currentTrackIndex - 1 + exampleTracks.length) % exampleTracks.length\n playTrack(prevIndex)\n }\n\n const playScratchSound = (position: number, speed: number = 1) => {\n if (!audioContextRef.current) {\n const audioContext = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)()\n audioContextRef.current = audioContext\n }\n\n if (audioContextRef.current.state === \"suspended\") {\n audioContextRef.current.resume()\n }\n\n if (!audioBufferRef.current) {\n return\n }\n\n stopScratchSound()\n\n try {\n const source = audioContextRef.current.createBufferSource()\n source.buffer = audioBufferRef.current\n\n const startTime = Math.max(\n 0,\n Math.min(\n audioBufferRef.current.duration - 0.1,\n position * audioBufferRef.current.duration\n )\n )\n\n const filter = audioContextRef.current.createBiquadFilter()\n filter.type = \"lowpass\"\n filter.frequency.value = Math.max(800, 2500 - speed * 1500)\n filter.Q.value = 3\n\n source.playbackRate.value = Math.max(0.4, Math.min(2.5, 1 + speed * 0.5))\n\n const gainNode = audioContextRef.current.createGain()\n gainNode.gain.value = 1.0\n\n source.connect(filter)\n filter.connect(gainNode)\n gainNode.connect(audioContextRef.current.destination)\n\n source.start(0, startTime, 0.06)\n\n scratchBufferRef.current = source\n } catch (error) {\n console.error(\"Error playing scratch sound:\", error)\n }\n }\n\n const stopScratchSound = () => {\n if (scratchBufferRef.current) {\n try {\n scratchBufferRef.current.stop()\n } catch {}\n scratchBufferRef.current = null\n }\n }\n\n const tracks = exampleTracks.map((t) => ({\n id: t.id,\n title: t.name,\n artist: \"ElevenMusic\",\n }))\n const currentTrack = tracks[currentTrackIndex]\n\n return (\n \n
\n
\n\n
\n
\n
\n
\n
\n

\n {currentTrack.title}\n

\n \n {currentTrack.artist}\n \n
\n
\n setAmbienceMode(!ambienceMode)}\n >\n \n \n setShowTrackList(!showTrackList)}\n >\n \n \n
\n
\n\n {\n e.preventDefault()\n setIsScrubbing(true)\n\n const wasPlaying = isPlayingRef.current\n\n if (isPlayingRef.current) {\n playerApiRef.current.pause()\n }\n\n const rect = e.currentTarget.getBoundingClientRect()\n const startX = e.touches[0].clientX\n const containerWidth = rect.width\n containerWidthRef.current = containerWidth\n const totalWidth = totalBarsRef.current * 5\n const currentOffset = waveformOffset.current\n let lastTouchX = startX\n let lastScratchTime = 0\n const scratchThrottle = 10\n\n let velocity = 0\n let lastTime = Date.now()\n let lastClientX = e.touches[0].clientX\n\n const handleMove = (e: TouchEvent) => {\n const touch = e.touches[0]\n const deltaX = touch.clientX - startX\n const newOffset = currentOffset + deltaX\n\n const minOffset = containerWidth - totalWidth\n const maxOffset = containerWidth\n const clampedOffset = Math.max(\n minOffset,\n Math.min(maxOffset, newOffset)\n )\n waveformOffset.current = clampedOffset\n if (waveformElementRef.current) {\n waveformElementRef.current.style.transform = `translateX(${clampedOffset}px)`\n }\n\n const position = Math.max(\n 0,\n Math.min(1, (containerWidth - clampedOffset) / totalWidth)\n )\n\n const audioEl = playerApiRef.current.ref.current\n if (audioEl && !isNaN(audioEl.duration)) {\n audioEl.currentTime = position * audioEl.duration\n }\n\n const now = Date.now()\n const touchDelta = touch.clientX - lastTouchX\n\n const timeDelta = now - lastTime\n if (timeDelta > 0) {\n const instantVelocity =\n (touch.clientX - lastClientX) / timeDelta\n velocity = velocity * 0.6 + instantVelocity * 0.4\n }\n lastTime = now\n lastClientX = touch.clientX\n\n if (Math.abs(touchDelta) > 0) {\n if (now - lastScratchTime >= scratchThrottle) {\n const speed = Math.min(3, Math.abs(touchDelta) / 3)\n playScratchSound(position, speed)\n lastScratchTime = now\n }\n }\n lastTouchX = touch.clientX\n }\n\n const handleEnd = () => {\n setIsScrubbing(false)\n stopScratchSound()\n\n if (Math.abs(velocity) > 0.1) {\n setIsMomentumActive(true)\n let momentumOffset = waveformOffset.current\n let currentVelocity = velocity * 15\n const friction = 0.92\n const minVelocity = 0.5\n let lastScratchFrame = 0\n const scratchFrameInterval = 50\n\n const animateMomentum = () => {\n if (Math.abs(currentVelocity) > minVelocity) {\n momentumOffset += currentVelocity\n currentVelocity *= friction\n\n const minOffset = containerWidth - totalWidth\n const maxOffset = containerWidth\n const clampedOffset = Math.max(\n minOffset,\n Math.min(maxOffset, momentumOffset)\n )\n\n if (clampedOffset !== momentumOffset) {\n currentVelocity = 0\n }\n\n momentumOffset = clampedOffset\n waveformOffset.current = clampedOffset\n if (waveformElementRef.current) {\n waveformElementRef.current.style.transform = `translateX(${clampedOffset}px)`\n }\n\n const position = Math.max(\n 0,\n Math.min(\n 1,\n (containerWidth - clampedOffset) / totalWidth\n )\n )\n\n const audioEl2 = playerApiRef.current.ref.current\n if (audioEl2 && !isNaN(audioEl2.duration)) {\n audioEl2.currentTime = position * audioEl2.duration\n }\n\n const now = Date.now()\n if (now - lastScratchFrame >= scratchFrameInterval) {\n const speed = Math.min(\n 2.5,\n Math.abs(currentVelocity) / 10\n )\n if (speed > 0.1) {\n playScratchSound(position, speed)\n }\n lastScratchFrame = now\n }\n\n requestAnimationFrame(animateMomentum)\n } else {\n stopScratchSound()\n setIsMomentumActive(false)\n if (wasPlaying) {\n setTimeout(() => {\n playerApiRef.current.play()\n }, 10)\n }\n }\n }\n\n requestAnimationFrame(animateMomentum)\n } else {\n if (wasPlaying) {\n playerApiRef.current.play()\n }\n }\n\n document.removeEventListener(\"touchmove\", handleMove)\n document.removeEventListener(\"touchend\", handleEnd)\n }\n\n document.addEventListener(\"touchmove\", handleMove)\n document.addEventListener(\"touchend\", handleEnd)\n }}\n onMouseDown={(e) => {\n e.preventDefault()\n setIsScrubbing(true)\n\n const wasPlaying = isPlayingRef.current\n\n if (isPlayingRef.current) {\n playerApiRef.current.pause()\n }\n\n const rect = e.currentTarget.getBoundingClientRect()\n const startX = e.clientX\n const containerWidth = rect.width\n containerWidthRef.current = containerWidth\n const totalWidth = totalBarsRef.current * 5\n const currentOffset = waveformOffset.current\n let lastMouseX = startX\n let lastScratchTime = 0\n const scratchThrottle = 10\n\n let velocity = 0\n let lastTime = Date.now()\n let lastClientX = e.clientX\n\n const handleMove = (e: MouseEvent) => {\n const deltaX = e.clientX - startX\n const newOffset = currentOffset + deltaX\n\n const minOffset = containerWidth - totalWidth\n const maxOffset = containerWidth\n const clampedOffset = Math.max(\n minOffset,\n Math.min(maxOffset, newOffset)\n )\n waveformOffset.current = clampedOffset\n if (waveformElementRef.current) {\n waveformElementRef.current.style.transform = `translateX(${clampedOffset}px)`\n }\n\n const position = Math.max(\n 0,\n Math.min(1, (containerWidth - clampedOffset) / totalWidth)\n )\n\n const audioEl = playerApiRef.current.ref.current\n if (audioEl && !isNaN(audioEl.duration)) {\n audioEl.currentTime = position * audioEl.duration\n }\n\n const now = Date.now()\n const mouseDelta = e.clientX - lastMouseX\n\n const timeDelta = now - lastTime\n if (timeDelta > 0) {\n const instantVelocity =\n (e.clientX - lastClientX) / timeDelta\n velocity = velocity * 0.6 + instantVelocity * 0.4\n }\n lastTime = now\n lastClientX = e.clientX\n\n if (Math.abs(mouseDelta) > 0) {\n if (now - lastScratchTime >= scratchThrottle) {\n const speed = Math.min(3, Math.abs(mouseDelta) / 3)\n playScratchSound(position, speed)\n lastScratchTime = now\n }\n }\n lastMouseX = e.clientX\n }\n\n const handleUp = () => {\n setIsScrubbing(false)\n stopScratchSound()\n\n if (Math.abs(velocity) > 0.1) {\n setIsMomentumActive(true)\n let momentumOffset = waveformOffset.current\n let currentVelocity = velocity * 15\n const friction = 0.92\n const minVelocity = 0.5\n let lastScratchFrame = 0\n const scratchFrameInterval = 50\n\n const animateMomentum = () => {\n if (Math.abs(currentVelocity) > minVelocity) {\n momentumOffset += currentVelocity\n currentVelocity *= friction\n\n const minOffset = containerWidth - totalWidth\n const maxOffset = containerWidth\n const clampedOffset = Math.max(\n minOffset,\n Math.min(maxOffset, momentumOffset)\n )\n\n if (clampedOffset !== momentumOffset) {\n currentVelocity = 0\n }\n\n momentumOffset = clampedOffset\n waveformOffset.current = clampedOffset\n if (waveformElementRef.current) {\n waveformElementRef.current.style.transform = `translateX(${clampedOffset}px)`\n }\n\n const position = Math.max(\n 0,\n Math.min(\n 1,\n (containerWidth - clampedOffset) / totalWidth\n )\n )\n\n const audioEl2 = playerApiRef.current.ref.current\n if (audioEl2 && !isNaN(audioEl2.duration)) {\n audioEl2.currentTime = position * audioEl2.duration\n }\n\n const now = Date.now()\n if (now - lastScratchFrame >= scratchFrameInterval) {\n const speed = Math.min(\n 2.5,\n Math.abs(currentVelocity) / 10\n )\n if (speed > 0.1) {\n playScratchSound(position, speed)\n }\n lastScratchFrame = now\n }\n\n requestAnimationFrame(animateMomentum)\n } else {\n stopScratchSound()\n setIsMomentumActive(false)\n if (wasPlaying) {\n setTimeout(() => {\n playerApiRef.current.play()\n }, 10)\n }\n }\n }\n\n requestAnimationFrame(animateMomentum)\n } else {\n if (wasPlaying) {\n playerApiRef.current.play()\n }\n }\n\n document.removeEventListener(\"mousemove\", handleMove)\n document.removeEventListener(\"mouseup\", handleUp)\n }\n\n document.addEventListener(\"mousemove\", handleMove)\n document.addEventListener(\"mouseup\", handleUp)\n }}\n >\n
\n \n 0\n ? precomputedWaveform\n : audioDataRef.current\n }\n height={32}\n barWidth={3}\n barGap={2}\n fadeEdges={true}\n fadeWidth={24}\n barRadius={1}\n barColor={isDark ? \"#a1a1aa\" : \"#71717a\"}\n />\n
\n
\n
\n\n \n
\n
\n\n {showTrackList && (\n
\n

\n Playlist\n

\n
\n {tracks.map((track, index) => (\n playTrack(index)}\n className={cn(\n \"w-full rounded px-2 py-1 text-left text-xs transition-all\",\n currentTrackIndex === index\n ? \"bg-foreground/10 text-foreground dark:bg-primary/20 dark:text-primary\"\n : \"hover:bg-muted text-muted-foreground\"\n )}\n >\n
\n \n {index + 1}\n \n
\n
{track.title}
\n
\n {track.artist}\n
\n
\n
\n \n ))}\n
\n
\n )}\n\n
\n \n \n \n\n \n\n \n \n \n
\n\n \n\n \n
\n
\n )\n}\n", "type": "registry:component", "target": "components/speaker.tsx" } ], "meta": { "iframeHeight": "900px", "container": "w-full bg-surface min-h-svh flex px-4 py-12 items-center md:py-20 justify-center min-w-0", "mobile": "component" }, "categories": [ "audio" ], "type": "registry:block" }