{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A play/pause control shaped like a spinning record, with a live waveform and seekable progress ring.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Pause, Play } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useAnimationFrame,\n  useMotionValue,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nconst DEFAULT_SIZE = 96;\nconst DEFAULT_BARS = 24;\nconst RING_RADIUS = 46;\nconst RING_VIEWBOX = 100;\nconst RING_STROKE_WIDTH = 4;\nconst ROTATION_DEG_PER_MS = 360 / 15_000;\nconst SEEK_STEP = 0.05;\nconst BAR_MIN_SCALE = 0.2;\nconst BAR_MAX_SCALE = 1;\nconst BAR_BASE_DURATION = 0.6;\nconst BAR_DURATION_JITTER = 0.5;\nconst HASH_MULTIPLIER = 12.9898;\nconst HASH_SCALE = 43_758.5453;\nconst SPRING = { bounce: 0.1, duration: 0.25, type: \"spring\" as const };\nconst MOVEMENT_EASE: [number, number, number, number] = [\n  0.645, 0.045, 0.355, 1,\n];\n\nexport type MusicToggleProps = {\n  /** Number of waveform bars rendered next to the track info */\n  bars?: number;\n  /** Additional CSS classes */\n  className?: string;\n  /** Cover art image url shown inside the spinning record */\n  cover?: string;\n  /** Default playing state for uncontrolled usage */\n  defaultPlaying?: boolean;\n  /** Called when the seek slider is used to change progress */\n  onSeek?: (progress: number) => void;\n  /** Called whenever the playing state changes */\n  onPlayingChange?: (playing: boolean) => void;\n  /** Controlled playing state */\n  playing?: boolean;\n  /** Current playback progress, from 0 to 1 */\n  progress?: number;\n  /** Diameter of the record, in pixels */\n  size?: number;\n  /**\n   * Audio source. When provided the component owns an `<audio>` element and\n   * plays/pauses it. When omitted the component is purely visual and the\n   * caller drives `playing` externally.\n   */\n  src?: string;\n  /** Track artist, shown under the title */\n  artist?: string;\n  /** Track title, shown above the artist and read by assistive tech */\n  title?: string;\n};\n\nconst hashUnit = (seed: number) => {\n  const x = Math.sin(seed * HASH_MULTIPLIER) * HASH_SCALE;\n  return x - Math.floor(x);\n};\n\nconst clampUnit = (value: number) => Math.min(1, Math.max(0, value));\n\nconst MusicToggle = ({\n  playing: controlledPlaying,\n  defaultPlaying = false,\n  onPlayingChange,\n  src,\n  cover,\n  title,\n  artist,\n  bars = DEFAULT_BARS,\n  progress = 0,\n  onSeek,\n  size = DEFAULT_SIZE,\n  className,\n}: MusicToggleProps) => {\n  const shouldReduceMotion = !!useReducedMotion();\n  const audioRef = useRef<HTMLAudioElement>(null);\n  const mountedRef = useRef(false);\n  const rotation = useMotionValue(0);\n\n  const [internalPlaying, setInternalPlaying] = useState(defaultPlaying);\n  const isControlled = controlledPlaying !== undefined;\n  const playing = isControlled ? controlledPlaying : internalPlaying;\n\n  const handleToggle = useCallback(() => {\n    const next = !playing;\n    if (!isControlled) {\n      setInternalPlaying(next);\n    }\n    onPlayingChange?.(next);\n  }, [playing, isControlled, onPlayingChange]);\n\n  useAnimationFrame((_, delta) => {\n    if (!playing || shouldReduceMotion) {\n      return;\n    }\n    rotation.set(rotation.get() + delta * ROTATION_DEG_PER_MS);\n  });\n\n  useEffect(() => {\n    if (!mountedRef.current) {\n      mountedRef.current = true;\n      return;\n    }\n    const audio = audioRef.current;\n    if (!audio) {\n      return;\n    }\n    if (playing) {\n      audio.play().catch(() => {\n        // Playback was blocked or interrupted; visual state stays in sync.\n      });\n    } else {\n      audio.pause();\n    }\n  }, [playing]);\n\n  const handleSeekKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (!onSeek) {\n      return;\n    }\n    switch (event.key) {\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        event.preventDefault();\n        onSeek(clampUnit(progress + SEEK_STEP));\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        event.preventDefault();\n        onSeek(clampUnit(progress - SEEK_STEP));\n        break;\n      case \"Home\":\n        event.preventDefault();\n        onSeek(0);\n        break;\n      case \"End\":\n        event.preventDefault();\n        onSeek(1);\n        break;\n      default:\n        break;\n    }\n  };\n\n  const circumference = 2 * Math.PI * RING_RADIUS;\n  const label = [title, artist].filter(Boolean).join(\" — \");\n  const toggleLabel = `${playing ? \"Pause\" : \"Play\"}${label ? ` ${label}` : \"\"}`;\n\n  return (\n    <div className={cn(\"inline-flex items-center gap-3\", className)}>\n      <div className=\"relative shrink-0\" style={{ height: size, width: size }}>\n        <div\n          aria-label={label ? `Seek ${label}` : \"Seek track\"}\n          aria-valuemax={100}\n          aria-valuemin={0}\n          aria-valuenow={Math.round(progress * 100)}\n          className=\"pointer-events-none absolute inset-0 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n          onKeyDown={handleSeekKeyDown}\n          role=\"slider\"\n          tabIndex={onSeek ? 0 : -1}\n        >\n          <svg\n            className=\"h-full w-full -rotate-90\"\n            viewBox={`0 0 ${RING_VIEWBOX} ${RING_VIEWBOX}`}\n          >\n            <circle\n              className=\"text-foreground/10\"\n              cx=\"50\"\n              cy=\"50\"\n              fill=\"none\"\n              r={RING_RADIUS}\n              stroke=\"currentColor\"\n              strokeWidth={RING_STROKE_WIDTH}\n            />\n            <motion.circle\n              animate={{\n                strokeDashoffset: circumference * (1 - progress),\n              }}\n              className=\"text-brand\"\n              cx=\"50\"\n              cy=\"50\"\n              fill=\"none\"\n              r={RING_RADIUS}\n              stroke=\"currentColor\"\n              strokeDasharray={circumference}\n              strokeLinecap=\"round\"\n              strokeWidth={RING_STROKE_WIDTH}\n              transition={\n                shouldReduceMotion\n                  ? { duration: 0 }\n                  : { duration: 0.25, ease: MOVEMENT_EASE }\n              }\n            />\n          </svg>\n        </div>\n\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"absolute inset-[10%] overflow-hidden rounded-full border border-border bg-muted\"\n          style={{ rotate: rotation }}\n        >\n          {cover ? (\n            <img alt=\"\" className=\"h-full w-full object-cover\" src={cover} />\n          ) : (\n            <div className=\"h-full w-full bg-gradient-to-br from-brand/40 to-brand/10\" />\n          )}\n          <div className=\"absolute inset-[38%] rounded-full bg-background\" />\n        </motion.div>\n\n        <button\n          aria-label={toggleLabel}\n          aria-pressed={playing}\n          className=\"absolute inset-[38%] z-10 flex items-center justify-center rounded-full bg-background text-foreground shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n          onClick={handleToggle}\n          type=\"button\"\n        >\n          <AnimatePresence initial={false} mode=\"wait\">\n            {playing ? (\n              <motion.span\n                animate={\n                  shouldReduceMotion\n                    ? { opacity: 1 }\n                    : { opacity: 1, rotate: 0, scale: 1 }\n                }\n                exit={\n                  shouldReduceMotion\n                    ? { opacity: 0, transition: { duration: 0 } }\n                    : { opacity: 0, rotate: -45, scale: 0.6 }\n                }\n                initial={\n                  shouldReduceMotion\n                    ? { opacity: 0 }\n                    : { opacity: 0, rotate: 45, scale: 0.6 }\n                }\n                key=\"pause\"\n                transition={shouldReduceMotion ? { duration: 0 } : SPRING}\n              >\n                <Pause className=\"size-4 fill-current\" />\n              </motion.span>\n            ) : (\n              <motion.span\n                animate={\n                  shouldReduceMotion\n                    ? { opacity: 1 }\n                    : { opacity: 1, rotate: 0, scale: 1 }\n                }\n                exit={\n                  shouldReduceMotion\n                    ? { opacity: 0, transition: { duration: 0 } }\n                    : { opacity: 0, rotate: 45, scale: 0.6 }\n                }\n                initial={\n                  shouldReduceMotion\n                    ? { opacity: 0 }\n                    : { opacity: 0, rotate: -45, scale: 0.6 }\n                }\n                key=\"play\"\n                transition={shouldReduceMotion ? { duration: 0 } : SPRING}\n              >\n                <Play className=\"size-4 fill-current\" />\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </button>\n      </div>\n\n      <div className=\"flex flex-col gap-1.5\">\n        {title || artist ? (\n          <div className=\"flex flex-col\">\n            {title ? (\n              <span className=\"font-medium text-foreground text-sm\">\n                {title}\n              </span>\n            ) : null}\n            {artist ? (\n              <span className=\"text-muted-foreground text-xs\">{artist}</span>\n            ) : null}\n          </div>\n        ) : null}\n        <div aria-hidden=\"true\" className=\"flex h-5 items-end gap-[2px]\">\n          {Array.from({ length: bars }).map((_, index) => {\n            const heightSeed = hashUnit(index);\n            const durationSeed = hashUnit(index + 0.5);\n            const delaySeed = hashUnit(index + 0.25);\n            const minScale = BAR_MIN_SCALE + heightSeed * 0.15;\n            const maxScale = BAR_MAX_SCALE - (1 - heightSeed) * 0.3;\n            const duration =\n              BAR_BASE_DURATION + durationSeed * BAR_DURATION_JITTER;\n            const delay = delaySeed * duration;\n            const animate =\n              playing && !shouldReduceMotion\n                ? { scaleY: [minScale, maxScale, minScale] }\n                : { scaleY: minScale };\n            const transition =\n              playing && !shouldReduceMotion\n                ? {\n                    delay,\n                    duration,\n                    ease: MOVEMENT_EASE,\n                    repeat: Number.POSITIVE_INFINITY,\n                    repeatType: \"mirror\" as const,\n                  }\n                : { duration: 0 };\n            return (\n              <motion.span\n                animate={animate}\n                className=\"w-[2px] rounded-full bg-foreground/70\"\n                // biome-ignore lint/suspicious/noArrayIndexKey: fixed-count, order-stable waveform bars\n                key={index}\n                style={{ height: \"100%\", transformOrigin: \"bottom\" }}\n                transition={transition}\n              />\n            );\n          })}\n        </div>\n      </div>\n\n      {src ? (\n        <audio ref={audioRef} src={src}>\n          <track kind=\"captions\" />\n        </audio>\n      ) : null}\n    </div>\n  );\n};\n\nexport default MusicToggle;\n","path":"index.tsx","target":"components/smoothui/music-toggle/index.tsx","type":"registry:ui"}],"name":"music-toggle","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Music Toggle","type":"registry:ui"}