{
	"$schema": "https://shadcn-svelte.com/schema/registry-item.json",
	"name": "speaker-01",
	"title": "Speaker 01",
	"type": "registry:block",
	"description": "A speaker-style music player with two audio-reactive Orbs, waveform strip, transport controls, and a volume slider.",
	"devDependencies": [
		"@lucide/svelte@^1.7.0"
	],
	"registryDependencies": [
		"https://sv11.ui.twango.dev/r/audio-player.json",
		"button",
		"card",
		"https://sv11.ui.twango.dev/r/orb.json",
		"https://sv11.ui.twango.dev/r/waveform.json"
	],
	"files": [
		{
			"content": "<script lang=\"ts\">\n\timport { AudioPlayer } from \"$UI$/audio-player/index.js\";\n\timport SpeakerInner from \"./speaker-inner.svelte\";\n\n\tlet { class: className }: { class?: string } = $props();\n</script>\n\n<AudioPlayer>\n\t<SpeakerInner class={className} />\n</AudioPlayer>\n",
			"type": "registry:block",
			"target": "components/blocks/speaker-01/speaker-01.svelte"
		},
		{
			"content": "<script lang=\"ts\">\n\timport { untrack } from \"svelte\";\n\timport Music from \"@lucide/svelte/icons/music\";\n\timport SkipBack from \"@lucide/svelte/icons/skip-back\";\n\timport SkipForward from \"@lucide/svelte/icons/skip-forward\";\n\timport Sparkles from \"@lucide/svelte/icons/sparkles\";\n\timport Volume from \"@lucide/svelte/icons/volume\";\n\timport Volume1 from \"@lucide/svelte/icons/volume-1\";\n\timport Volume2 from \"@lucide/svelte/icons/volume-2\";\n\timport VolumeX from \"@lucide/svelte/icons/volume-x\";\n\timport {\n\t\tAudioGraph,\n\t\tAudioPlayerButton,\n\t\tAudioPlayerDuration,\n\t\tAudioPlayerProgress,\n\t\tAudioPlayerTime,\n\t\texampleTracks,\n\t\tprecomputeWaveform as decodeAndSampleWaveform,\n\t\tuseAudioPlayer,\n\t} from \"$UI$/audio-player/index.js\";\n\timport { Button } from \"$UI$/button/index.js\";\n\timport { Card } from \"$UI$/card/index.js\";\n\timport { Waveform } from \"$UI$/waveform/index.js\";\n\timport { cn } from \"$UTILS$.js\";\n\timport SpeakerOrb from \"./speaker-orb.svelte\";\n\timport { useScratchableWaveform } from \"./use-scratchable-waveform.svelte.js\";\n\n\tlet { class: className }: { class?: string } = $props();\n\n\tconst player = useAudioPlayer<{ name: string }>();\n\tconst graph = new AudioGraph();\n\n\tlet currentTrackIndex = $state(0);\n\tlet showTrackList = $state(false);\n\tlet ambienceMode = $state(false);\n\tlet volume = $state(0.7);\n\tlet isDark = $state(false);\n\n\tconst audioDataRef: { current: number[] } = $state({ current: [] });\n\tconst isPlayingRef: { current: boolean } = $state({ current: false });\n\tconst volumeRef: { current: number } = $state({ current: 0.7 });\n\n\tconst BARS_PER_SECOND = 8;\n\tconst BAR_STEP = 5;\n\tlet precomputedWaveform = $state<number[]>([]);\n\tlet loadedTrackId = $state<string | null>(null);\n\tlet containerWidthRef = { current: 300 };\n\tlet waveformContainerEl = $state<HTMLDivElement | null>(null);\n\tconst totalWidth = $derived(precomputedWaveform.length * BAR_STEP);\n\n\tconst scrub = useScratchableWaveform({\n\t\tplayer,\n\t\tgraph,\n\t\ttrackUrl: () => exampleTracks[currentTrackIndex]?.url ?? null,\n\t\ttotalWidth: () => totalWidth,\n\t\tcontainerWidth: () => containerWidthRef.current,\n\t});\n\n\t$effect(() => {\n\t\tvolumeRef.current = volume;\n\t});\n\n\tlet waveformRequestId = 0;\n\tasync function loadWaveform(track: (typeof exampleTracks)[number]) {\n\t\tconst requestId = ++waveformRequestId;\n\t\tlet bars: number[] | null = null;\n\n\t\tif (track.waveform && track.waveform.length > 0) {\n\t\t\tbars = track.waveform;\n\t\t} else if (track.waveformUrl) {\n\t\t\ttry {\n\t\t\t\tconst response = await fetch(track.waveformUrl);\n\t\t\t\tif (response.ok) {\n\t\t\t\t\tconst json = await response.json();\n\t\t\t\t\tif (Array.isArray(json)) bars = json;\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// fall through to client decode\n\t\t\t}\n\t\t}\n\n\t\tif (!bars) {\n\t\t\ttry {\n\t\t\t\tbars = await decodeAndSampleWaveform(track.url, BARS_PER_SECOND);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Error decoding waveform:\", error);\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tif (requestId === waveformRequestId) {\n\t\t\tprecomputedWaveform = bars;\n\t\t\tloadedTrackId = track.id;\n\t\t}\n\t}\n\n\t$effect(() => {\n\t\tconst measure = () => {\n\t\t\tif (waveformContainerEl) {\n\t\t\t\tcontainerWidthRef.current = waveformContainerEl.getBoundingClientRect().width;\n\t\t\t}\n\t\t};\n\t\tmeasure();\n\t\twindow.addEventListener(\"resize\", measure);\n\t\treturn () => window.removeEventListener(\"resize\", measure);\n\t});\n\n\t$effect(() => {\n\t\tif (loadedTrackId !== null && containerWidthRef.current > 0) {\n\t\t\tscrub.offset = containerWidthRef.current;\n\t\t}\n\t});\n\n\t$effect(() => {\n\t\tuntrack(() => void loadWaveform(exampleTracks[0]));\n\t});\n\n\t$effect(() => {\n\t\tif (!player.audio) return;\n\t\tuntrack(() => {\n\t\t\tconst track = exampleTracks[0];\n\t\t\tvoid player.setActiveItem({ id: track.id, src: track.url, data: { name: track.name } });\n\t\t});\n\t});\n\n\t// Playhead RAF: only drives offset when the user isn't driving it themselves.\n\t$effect(() => {\n\t\tlet animationId: number;\n\t\tconst update = () => {\n\t\t\tif (!scrub.isScrubbing && !scrub.isMomentumActive) {\n\t\t\t\tconst audio = player.audio;\n\t\t\t\tif (audio && !isNaN(audio.duration) && audio.duration > 0) {\n\t\t\t\t\tconst position = audio.currentTime / audio.duration;\n\t\t\t\t\tscrub.offset = containerWidthRef.current - position * totalWidth;\n\t\t\t\t}\n\t\t\t}\n\t\t\tanimationId = requestAnimationFrame(update);\n\t\t};\n\t\tanimationId = requestAnimationFrame(update);\n\t\treturn () => cancelAnimationFrame(animationId);\n\t});\n\n\t$effect(() => {\n\t\tisPlayingRef.current = player.isPlaying;\n\t});\n\n\t$effect(() => {\n\t\tif (player.audio) player.audio.volume = volume;\n\t});\n\n\t$effect(() => {\n\t\tconst check = () => {\n\t\t\tisDark = document.documentElement.classList.contains(\"dark\");\n\t\t};\n\t\tcheck();\n\t\tconst observer = new MutationObserver(check);\n\t\tobserver.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\t\treturn () => observer.disconnect();\n\t});\n\n\t$effect(() => {\n\t\tconst audioEl = player.audio;\n\t\tif (!audioEl) return;\n\t\tconst handleStart = () => graph.ensureAnalyser(audioEl);\n\t\taudioEl.addEventListener(\"play\", handleStart);\n\t\treturn () => {\n\t\t\taudioEl.removeEventListener(\"play\", handleStart);\n\t\t\tgraph.destroy();\n\t\t\tscrub.reset();\n\t\t};\n\t});\n\n\t$effect(() => {\n\t\tlet animationId: number;\n\t\tconst loop = () => {\n\t\t\tconst analyser = graph.analyser;\n\t\t\tif (analyser && isPlayingRef.current) {\n\t\t\t\tconst arr = new Uint8Array(analyser.frequencyBinCount);\n\t\t\t\tanalyser.getByteFrequencyData(arr);\n\t\t\t\taudioDataRef.current = Array.from(arr).map((v) => v / 255);\n\t\t\t} else if (!isPlayingRef.current && audioDataRef.current.length > 0) {\n\t\t\t\taudioDataRef.current = audioDataRef.current.map((v) => v * 0.9);\n\t\t\t}\n\t\t\tanimationId = requestAnimationFrame(loop);\n\t\t};\n\t\tanimationId = requestAnimationFrame(loop);\n\t\treturn () => cancelAnimationFrame(animationId);\n\t});\n\n\tfunction playTrack(index: number) {\n\t\tcurrentTrackIndex = index;\n\t\tconst track = exampleTracks[index];\n\t\tvoid player.play({ id: track.id, src: track.url, data: { name: track.name } });\n\t\tshowTrackList = false;\n\t\tscrub.reset();\n\t\tvoid loadWaveform(track);\n\t}\n\n\tfunction nextTrack() {\n\t\tplayTrack((currentTrackIndex + 1) % exampleTracks.length);\n\t}\n\n\tfunction prevTrack() {\n\t\tplayTrack((currentTrackIndex - 1 + exampleTracks.length) % exampleTracks.length);\n\t}\n\n\tconst tracks = exampleTracks.map((t) => ({\n\t\tid: t.id,\n\t\ttitle: t.name,\n\t\tartist: \"SoundHelix\",\n\t}));\n\tconst currentTrack = $derived(tracks[currentTrackIndex]);\n\n\tconst VolumeIcon = $derived(\n\t\tvolume === 0 ? VolumeX : volume <= 0.33 ? Volume : volume <= 0.66 ? Volume1 : Volume2\n\t);\n\n\tconst scrubberValue = $derived.by(() => {\n\t\tconst audio = player.audio;\n\t\tif (!audio || !isFinite(audio.duration) || audio.duration <= 0) return 0;\n\t\treturn Math.round((audio.currentTime / audio.duration) * 100);\n\t});\n\n\tfunction handleVolumeClick(event: MouseEvent) {\n\t\tconst target = event.currentTarget as HTMLDivElement;\n\t\tconst rect = target.getBoundingClientRect();\n\t\tvolume = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));\n\t}\n</script>\n\n<Card class={cn(\"relative\", className)}>\n\t<div\n\t\tclass=\"bg-muted-foreground/30 absolute top-0 left-1/2 h-3 w-48 -translate-x-1/2 rounded-b-full\"\n\t></div>\n\t<div\n\t\tclass=\"bg-muted-foreground/20 absolute top-0 left-1/2 h-2 w-44 -translate-x-1/2 rounded-b-full\"\n\t></div>\n\n\t<div class=\"relative space-y-6 p-4\">\n\t\t<div class=\"border-border rounded-lg border bg-black/5 p-4 backdrop-blur-sm dark:bg-black/50\">\n\t\t\t<div class=\"space-y-2\">\n\t\t\t\t<div class=\"flex items-center justify-between\">\n\t\t\t\t\t<div class=\"min-w-0 flex-1\">\n\t\t\t\t\t\t<h3 class=\"text-foreground truncate text-sm font-medium\">{currentTrack.title}</h3>\n\t\t\t\t\t\t<p class=\"text-muted-foreground truncate text-xs\">{currentTrack.artist}</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"flex gap-1\">\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\t\tclass={cn(\n\t\t\t\t\t\t\t\t\"h-8 w-8 transition-all\",\n\t\t\t\t\t\t\t\tambienceMode\n\t\t\t\t\t\t\t\t\t? \"text-primary hover:text-primary/80\"\n\t\t\t\t\t\t\t\t\t: \"text-muted-foreground hover:text-foreground\"\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\tonclick={() => (ambienceMode = !ambienceMode)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Sparkles class=\"h-4 w-4\" />\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\t\tclass=\"text-muted-foreground hover:text-foreground h-8 w-8\"\n\t\t\t\t\t\t\tonclick={() => (showTrackList = !showTrackList)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Music class=\"h-4 w-4\" />\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div\n\t\t\t\t\tbind:this={waveformContainerEl}\n\t\t\t\t\tclass=\"waveform-container bg-foreground/10 relative h-12 cursor-grab touch-none overflow-hidden rounded-lg p-2 select-none active:cursor-grabbing dark:bg-black/80\"\n\t\t\t\t\trole=\"slider\"\n\t\t\t\t\ttabindex=\"0\"\n\t\t\t\t\taria-label=\"Seek playback\"\n\t\t\t\t\taria-valuemin=\"0\"\n\t\t\t\t\taria-valuemax=\"100\"\n\t\t\t\t\taria-valuenow={scrubberValue}\n\t\t\t\t\tonpointerdown={scrub.handlePointerDown}\n\t\t\t\t\tonkeydown={scrub.handleKeyDown}\n\t\t\t\t>\n\t\t\t\t\t<div class=\"relative h-full w-full overflow-hidden\">\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tstyle:transform=\"translateX({scrub.offset}px)\"\n\t\t\t\t\t\t\tstyle:transition={scrub.isScrubbing || scrub.isMomentumActive\n\t\t\t\t\t\t\t\t? \"none\"\n\t\t\t\t\t\t\t\t: \"transform 0.016s linear\"}\n\t\t\t\t\t\t\tstyle:width=\"{totalWidth}px\"\n\t\t\t\t\t\t\tstyle:position=\"absolute\"\n\t\t\t\t\t\t\tstyle:left=\"0\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Waveform\n\t\t\t\t\t\t\t\tdata={precomputedWaveform.length > 0 ? precomputedWaveform : audioDataRef.current}\n\t\t\t\t\t\t\t\theight={32}\n\t\t\t\t\t\t\t\tbarWidth={3}\n\t\t\t\t\t\t\t\tbarGap={2}\n\t\t\t\t\t\t\t\tfadeEdges={true}\n\t\t\t\t\t\t\t\tfadeWidth={24}\n\t\t\t\t\t\t\t\tbarRadius={1}\n\t\t\t\t\t\t\t\tbarColor={isDark ? \"#a1a1aa\" : \"#71717a\"}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div class=\"flex items-center gap-2\">\n\t\t\t\t\t<AudioPlayerTime class=\"text-xs\" />\n\t\t\t\t\t<AudioPlayerProgress class=\"flex-1\" />\n\t\t\t\t\t<AudioPlayerDuration class=\"text-xs\" />\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t{#if showTrackList}\n\t\t\t<div\n\t\t\t\tclass=\"bg-card/95 border-border absolute top-36 right-8 left-8 z-10 rounded-lg border p-3 shadow-xl backdrop-blur\"\n\t\t\t>\n\t\t\t\t<h4 class=\"text-muted-foreground mb-2 font-mono text-xs tracking-wider uppercase\">\n\t\t\t\t\tPlaylist\n\t\t\t\t</h4>\n\t\t\t\t<div class=\"max-h-32 space-y-1 overflow-y-auto\">\n\t\t\t\t\t{#each tracks as track, index (track.id)}\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonclick={() => playTrack(index)}\n\t\t\t\t\t\t\tclass={cn(\n\t\t\t\t\t\t\t\t\"w-full rounded px-2 py-1 text-left text-xs transition-all\",\n\t\t\t\t\t\t\t\tcurrentTrackIndex === index\n\t\t\t\t\t\t\t\t\t? \"bg-foreground/10 text-foreground dark:bg-primary/20 dark:text-primary\"\n\t\t\t\t\t\t\t\t\t: \"hover:bg-muted text-muted-foreground\"\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div class=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t<span class=\"text-muted-foreground/60\">{index + 1}</span>\n\t\t\t\t\t\t\t\t<div class=\"min-w-0 flex-1\">\n\t\t\t\t\t\t\t\t\t<div class=\"truncate\">{track.title}</div>\n\t\t\t\t\t\t\t\t\t<div class=\"text-muted-foreground/60 truncate text-xs\">{track.artist}</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t{/each}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t{/if}\n\n\t\t<div class=\"flex justify-center gap-3\">\n\t\t\t<Button\n\t\t\t\tvariant=\"outline\"\n\t\t\t\tsize=\"icon\"\n\t\t\t\tclass=\"border-border bg-background hover:bg-muted h-10 w-10 rounded-full\"\n\t\t\t\tonclick={prevTrack}\n\t\t\t>\n\t\t\t\t<SkipBack class=\"text-muted-foreground h-4 w-4\" />\n\t\t\t</Button>\n\t\t\t<AudioPlayerButton\n\t\t\t\tvariant=\"outline\"\n\t\t\t\tsize=\"icon\"\n\t\t\t\titem={{\n\t\t\t\t\tid: exampleTracks[currentTrackIndex].id,\n\t\t\t\t\tsrc: exampleTracks[currentTrackIndex].url,\n\t\t\t\t\tdata: { name: exampleTracks[currentTrackIndex].name },\n\t\t\t\t}}\n\t\t\t\tclass={cn(\n\t\t\t\t\t\"border-border h-14 w-14 rounded-full transition-all duration-300\",\n\t\t\t\t\tplayer.isPlaying\n\t\t\t\t\t\t? \"bg-foreground/10 hover:bg-foreground/15 border-foreground/30 dark:bg-primary/20 dark:hover:bg-primary/30 dark:border-primary/50\"\n\t\t\t\t\t\t: \"bg-background hover:bg-muted\"\n\t\t\t\t)}\n\t\t\t/>\n\t\t\t<Button\n\t\t\t\tvariant=\"outline\"\n\t\t\t\tsize=\"icon\"\n\t\t\t\tclass=\"border-border bg-background hover:bg-muted h-10 w-10 rounded-full\"\n\t\t\t\tonclick={nextTrack}\n\t\t\t>\n\t\t\t\t<SkipForward class=\"text-muted-foreground h-4 w-4\" />\n\t\t\t</Button>\n\t\t</div>\n\n\t\t<div class=\"mt-8 grid grid-cols-2 gap-8\">\n\t\t\t<div class=\"relative aspect-square\">\n\t\t\t\t<div\n\t\t\t\t\tclass=\"bg-muted relative h-full w-full rounded-full p-1 shadow-[inset_0_2px_8px_rgba(0,0,0,0.1)] dark:shadow-[inset_0_2px_8px_rgba(0,0,0,0.5)]\"\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclass=\"bg-background h-full w-full overflow-hidden rounded-full shadow-[inset_0_0_12px_rgba(0,0,0,0.05)] dark:shadow-[inset_0_0_12px_rgba(0,0,0,0.3)]\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<SpeakerOrb\n\t\t\t\t\t\t\tseed={100}\n\t\t\t\t\t\t\tside=\"left\"\n\t\t\t\t\t\t\t{isDark}\n\t\t\t\t\t\t\t{audioDataRef}\n\t\t\t\t\t\t\tisPlaying={isPlayingRef}\n\t\t\t\t\t\t\tvolume={volumeRef}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<div class=\"relative aspect-square\">\n\t\t\t\t<div\n\t\t\t\t\tclass=\"bg-muted relative h-full w-full rounded-full p-1 shadow-[inset_0_2px_8px_rgba(0,0,0,0.1)] dark:shadow-[inset_0_2px_8px_rgba(0,0,0,0.5)]\"\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclass=\"bg-background h-full w-full overflow-hidden rounded-full shadow-[inset_0_0_12px_rgba(0,0,0,0.05)] dark:shadow-[inset_0_0_12px_rgba(0,0,0,0.3)]\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<SpeakerOrb\n\t\t\t\t\t\t\tseed={2000}\n\t\t\t\t\t\t\tside=\"right\"\n\t\t\t\t\t\t\t{isDark}\n\t\t\t\t\t\t\t{audioDataRef}\n\t\t\t\t\t\t\tisPlaying={isPlayingRef}\n\t\t\t\t\t\t\tvolume={volumeRef}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div class=\"flex items-center justify-center gap-4 pt-4\">\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tonclick={() => (volume = volume > 0 ? 0 : 0.7)}\n\t\t\t\tclass=\"text-muted-foreground hover:text-foreground transition-colors\"\n\t\t\t\taria-label=\"Toggle mute\"\n\t\t\t>\n\t\t\t\t<VolumeIcon\n\t\t\t\t\tclass={cn(\"h-4 w-4 transition-all\", volume === 0 && \"text-muted-foreground/50\")}\n\t\t\t\t/>\n\t\t\t</button>\n\t\t\t<div\n\t\t\t\tclass=\"bg-foreground/10 group relative h-1 w-48 cursor-pointer rounded-full\"\n\t\t\t\trole=\"slider\"\n\t\t\t\ttabindex=\"0\"\n\t\t\t\taria-label=\"Volume\"\n\t\t\t\taria-valuemin=\"0\"\n\t\t\t\taria-valuemax=\"100\"\n\t\t\t\taria-valuenow={Math.round(volume * 100)}\n\t\t\t\tonclick={handleVolumeClick}\n\t\t\t\tonkeydown={(e) => {\n\t\t\t\t\tif (e.key === \"ArrowLeft\") volume = Math.max(0, volume - 0.05);\n\t\t\t\t\tif (e.key === \"ArrowRight\") volume = Math.min(1, volume + 0.05);\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<div\n\t\t\t\t\tclass=\"bg-primary absolute top-0 left-0 h-full rounded-full transition-all duration-150\"\n\t\t\t\t\tstyle:width=\"{volume * 100}%\"\n\t\t\t\t></div>\n\t\t\t</div>\n\t\t\t<span class=\"text-muted-foreground w-12 text-right font-mono text-xs\">\n\t\t\t\t{Math.round(volume * 100)}%\n\t\t\t</span>\n\t\t</div>\n\t</div>\n</Card>\n",
			"type": "registry:block",
			"target": "components/blocks/speaker-01/speaker-inner.svelte"
		},
		{
			"content": "<script lang=\"ts\">\n\timport { Orb } from \"$UI$/orb/index.js\";\n\n\tlet {\n\t\tseed,\n\t\tside,\n\t\tisDark,\n\t\taudioDataRef,\n\t\tisPlaying,\n\t\tvolume,\n\t}: {\n\t\tseed: number;\n\t\tside: \"left\" | \"right\";\n\t\tisDark: boolean;\n\t\taudioDataRef: { current: number[] };\n\t\tisPlaying: { current: boolean };\n\t\tvolume: { current: number };\n\t} = $props();\n\n\tconst colors = $derived<[string, string]>(\n\t\tisDark ? [\"#A0A0A0\", \"#232323\"] : [\"#F4F4F4\", \"#E0E0E0\"]\n\t);\n\n\tfunction getInputVolume(): number {\n\t\tconst audioData = audioDataRef.current;\n\t\tif (!isPlaying.current || volume.current === 0 || audioData.length === 0) return 0;\n\t\tconst lowFreqEnd = Math.floor(audioData.length * 0.25);\n\t\tlet sum = 0;\n\t\tfor (let i = 0; i < lowFreqEnd; i++) sum += audioData[i];\n\t\tconst avgLow = sum / lowFreqEnd;\n\t\tconst amplified = Math.pow(avgLow, 0.5) * 3.5;\n\t\treturn Math.max(0.2, Math.min(1.0, amplified));\n\t}\n\n\tfunction getOutputVolume(): number {\n\t\tconst audioData = audioDataRef.current;\n\t\tif (!isPlaying.current || volume.current === 0 || audioData.length === 0) return 0;\n\t\tconst midStart = Math.floor(audioData.length * 0.25);\n\t\tconst midEnd = Math.floor(audioData.length * 0.75);\n\t\tlet sum = 0;\n\t\tfor (let i = midStart; i < midEnd; i++) sum += audioData[i];\n\t\tconst avgMid = sum / (midEnd - midStart);\n\t\tconst modifier = side === \"left\" ? 0.9 : 1.1;\n\t\tconst amplified = Math.pow(avgMid, 0.5) * 4.0;\n\t\treturn Math.max(0.25, Math.min(1.0, amplified * modifier));\n\t}\n</script>\n\n<Orb {colors} {seed} volumeMode=\"manual\" {getInputVolume} {getOutputVolume} />\n",
			"type": "registry:block",
			"target": "components/blocks/speaker-01/speaker-orb.svelte"
		},
		{
			"content": "import Speaker01 from \"./speaker-01.svelte\";\n\nexport { Speaker01, Speaker01 as default };\n",
			"type": "registry:block",
			"target": "components/blocks/speaker-01/index.ts"
		},
		{
			"content": "import type { AudioGraph, AudioPlayerState } from \"$UI$/audio-player/index.js\";\n\n// Module-level cache, intentionally non-reactive: keyed by track URL so\n// navigating away and back reuses the decode, and concurrent pointerdowns\n// dedupe onto one in-flight promise.\n// eslint-disable-next-line svelte/prefer-svelte-reactivity\nconst scratchBufferCache = new Map<string, Promise<AudioBuffer | null>>();\n\nasync function fetchScratchBuffer(url: string, ctx: AudioContext): Promise<AudioBuffer | null> {\n\ttry {\n\t\tconst response = await fetch(url);\n\t\tconst arrayBuffer = await response.arrayBuffer();\n\t\treturn await ctx.decodeAudioData(arrayBuffer);\n\t} catch (error) {\n\t\tconsole.warn(\"Scratch buffer decode failed:\", error);\n\t\treturn null;\n\t}\n}\n\nfunction getScratchBuffer(url: string, ctx: AudioContext): Promise<AudioBuffer | null> {\n\tlet cached = scratchBufferCache.get(url);\n\tif (!cached) {\n\t\tcached = fetchScratchBuffer(url, ctx);\n\t\tscratchBufferCache.set(url, cached);\n\t}\n\treturn cached;\n}\n\nexport interface ScratchableWaveformOptions<T = unknown> {\n\tplayer: AudioPlayerState<T>;\n\tgraph: AudioGraph;\n\ttrackUrl: () => string | null;\n\ttotalWidth: () => number;\n\tcontainerWidth: () => number;\n}\n\nexport function useScratchableWaveform<T = unknown>(opts: ScratchableWaveformOptions<T>) {\n\tlet isScrubbing = $state(false);\n\tlet isMomentumActive = $state(false);\n\tlet offset = $state(0);\n\n\tlet scratchBuffer: AudioBuffer | null = null;\n\tlet scratchSource: AudioBufferSourceNode | null = null;\n\n\tfunction warmScratchBuffer(): void {\n\t\tif (scratchBuffer) return;\n\t\tconst url = opts.trackUrl();\n\t\tconst ctx = opts.graph.ensureContext();\n\t\tif (!url || !ctx) return;\n\t\tvoid getScratchBuffer(url, ctx).then((buf) => {\n\t\t\tif (buf && opts.trackUrl() === url) scratchBuffer = buf;\n\t\t});\n\t}\n\n\tfunction setOffsetAndSeek(next: number): void {\n\t\toffset = next;\n\t\tconst totalW = opts.totalWidth();\n\t\tif (totalW <= 0) return;\n\t\tconst position = Math.max(0, Math.min(1, (opts.containerWidth() - next) / totalW));\n\t\tconst audio = opts.player.audio;\n\t\tif (audio && isFinite(audio.duration)) audio.currentTime = position * audio.duration;\n\t}\n\n\tfunction playScratch(position: number, speed: number): void {\n\t\tconst ctx = opts.graph.ensureContext();\n\t\tif (!ctx || !scratchBuffer) return;\n\t\tif (ctx.state === \"suspended\") void ctx.resume().catch(() => {});\n\t\tstopScratch();\n\t\ttry {\n\t\t\tconst source = ctx.createBufferSource();\n\t\t\tsource.buffer = scratchBuffer;\n\t\t\tconst startTime = Math.max(\n\t\t\t\t0,\n\t\t\t\tMath.min(scratchBuffer.duration - 0.1, position * scratchBuffer.duration)\n\t\t\t);\n\t\t\tconst filter = ctx.createBiquadFilter();\n\t\t\tfilter.type = \"lowpass\";\n\t\t\tfilter.frequency.value = Math.max(800, 2500 - speed * 1500);\n\t\t\tfilter.Q.value = 3;\n\t\t\tsource.playbackRate.value = Math.max(0.4, Math.min(2.5, 1 + speed * 0.5));\n\t\t\tsource.connect(filter);\n\t\t\tfilter.connect(ctx.destination);\n\t\t\tsource.start(0, startTime, 0.06);\n\t\t\tscratchSource = source;\n\t\t} catch (error) {\n\t\t\tconsole.error(\"scratch playback failed:\", error);\n\t\t}\n\t}\n\n\tfunction stopScratch(): void {\n\t\tif (!scratchSource) return;\n\t\ttry {\n\t\t\tscratchSource.stop();\n\t\t} catch {\n\t\t\t// already stopped\n\t\t}\n\t\tscratchSource = null;\n\t}\n\n\tfunction handlePointerDown(e: PointerEvent): void {\n\t\tif (e.pointerType === \"mouse\" && e.button !== 0) return;\n\t\te.preventDefault();\n\t\twarmScratchBuffer();\n\n\t\tisScrubbing = true;\n\t\tconst wasPlaying = opts.player.isPlaying;\n\t\tif (wasPlaying) void opts.player.pause();\n\n\t\tconst target = e.currentTarget as HTMLElement;\n\t\tconst rect = target.getBoundingClientRect();\n\t\tconst startX = e.clientX;\n\t\tconst containerW = rect.width;\n\t\tconst totalW = opts.totalWidth();\n\t\tconst startOffset = offset;\n\t\tconst clamp = (v: number) => Math.max(containerW - totalW, Math.min(containerW, v));\n\t\tconst posAt = (o: number) => Math.max(0, Math.min(1, (containerW - o) / totalW));\n\n\t\tlet lastPointerX = startX;\n\t\tlet lastScratchTime = 0;\n\t\tlet velocity = 0;\n\t\tlet lastTime = Date.now();\n\t\tlet lastClientX = e.clientX;\n\n\t\tconst onMove = (ev: PointerEvent) => {\n\t\t\tif (ev.pointerId !== e.pointerId) return;\n\t\t\tconst clamped = clamp(startOffset + (ev.clientX - startX));\n\t\t\tsetOffsetAndSeek(clamped);\n\n\t\t\tconst now = Date.now();\n\t\t\tconst pointerDelta = ev.clientX - lastPointerX;\n\t\t\tconst timeDelta = now - lastTime;\n\t\t\tif (timeDelta > 0) {\n\t\t\t\tconst instant = (ev.clientX - lastClientX) / timeDelta;\n\t\t\t\tvelocity = velocity * 0.6 + instant * 0.4;\n\t\t\t}\n\t\t\tlastTime = now;\n\t\t\tlastClientX = ev.clientX;\n\n\t\t\tif (pointerDelta !== 0 && now - lastScratchTime >= 10) {\n\t\t\t\tplayScratch(posAt(clamped), Math.min(3, Math.abs(pointerDelta) / 3));\n\t\t\t\tlastScratchTime = now;\n\t\t\t}\n\t\t\tlastPointerX = ev.clientX;\n\t\t};\n\n\t\tconst onEnd = (ev: PointerEvent) => {\n\t\t\tif (ev.pointerId !== e.pointerId) return;\n\t\t\tdocument.removeEventListener(\"pointermove\", onMove);\n\t\t\tdocument.removeEventListener(\"pointerup\", onEnd);\n\t\t\tdocument.removeEventListener(\"pointercancel\", onEnd);\n\n\t\t\tisScrubbing = false;\n\t\t\tstopScratch();\n\n\t\t\tif (Math.abs(velocity) <= 0.1) {\n\t\t\t\tif (wasPlaying) void opts.player.play();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tisMomentumActive = true;\n\t\t\tlet current = offset;\n\t\t\tlet v = velocity * 15;\n\t\t\tlet lastFrame = 0;\n\n\t\t\tconst step = () => {\n\t\t\t\tif (Math.abs(v) <= 0.5) {\n\t\t\t\t\tstopScratch();\n\t\t\t\t\tisMomentumActive = false;\n\t\t\t\t\t// Delay the resume so the final scratch slice releases\n\t\t\t\t\t// before the media element starts — prevents a crackle.\n\t\t\t\t\tif (wasPlaying) setTimeout(() => void opts.player.play(), 10);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tcurrent += v;\n\t\t\t\tv *= 0.92;\n\t\t\t\tconst clamped = clamp(current);\n\t\t\t\tif (clamped !== current) v = 0;\n\t\t\t\tcurrent = clamped;\n\t\t\t\tsetOffsetAndSeek(clamped);\n\n\t\t\t\tconst now = Date.now();\n\t\t\t\tif (now - lastFrame >= 50) {\n\t\t\t\t\tconst speed = Math.min(2.5, Math.abs(v) / 10);\n\t\t\t\t\tif (speed > 0.1) playScratch(posAt(clamped), speed);\n\t\t\t\t\tlastFrame = now;\n\t\t\t\t}\n\t\t\t\trequestAnimationFrame(step);\n\t\t\t};\n\t\t\trequestAnimationFrame(step);\n\t\t};\n\n\t\tdocument.addEventListener(\"pointermove\", onMove);\n\t\tdocument.addEventListener(\"pointerup\", onEnd);\n\t\tdocument.addEventListener(\"pointercancel\", onEnd);\n\t}\n\n\tfunction handleKeyDown(e: KeyboardEvent): void {\n\t\tconst audio = opts.player.audio;\n\t\tif (!audio || !isFinite(audio.duration) || audio.duration <= 0) return;\n\t\tconst step = e.shiftKey ? 5 : 1;\n\t\tconst currentPct = (audio.currentTime / audio.duration) * 100;\n\t\tlet nextPct: number | null = null;\n\t\tif (e.key === \"ArrowLeft\") nextPct = Math.max(0, currentPct - step);\n\t\telse if (e.key === \"ArrowRight\") nextPct = Math.min(100, currentPct + step);\n\t\telse if (e.key === \"Home\") nextPct = 0;\n\t\telse if (e.key === \"End\") nextPct = 100;\n\t\tif (nextPct === null) return;\n\t\te.preventDefault();\n\t\taudio.currentTime = (nextPct / 100) * audio.duration;\n\t}\n\n\tfunction reset(): void {\n\t\tscratchBuffer = null;\n\t\tstopScratch();\n\t}\n\n\treturn {\n\t\tget isScrubbing() {\n\t\t\treturn isScrubbing;\n\t\t},\n\t\tget isMomentumActive() {\n\t\t\treturn isMomentumActive;\n\t\t},\n\t\tget offset() {\n\t\t\treturn offset;\n\t\t},\n\t\tset offset(value: number) {\n\t\t\toffset = value;\n\t\t},\n\t\thandlePointerDown,\n\t\thandleKeyDown,\n\t\treset,\n\t};\n}\n",
			"type": "registry:block",
			"target": "components/blocks/speaker-01/use-scratchable-waveform.svelte.ts"
		}
	]
}