{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "synced-lyric-captions-shadcnui",
  "type": "registry:component",
  "title": "Synced Lyric Captions",
  "description": "Bottom-to-top timed captions with play/pause controls and optional audio sync for songs or voiceovers.",
  "registryDependencies": [
    "button"
  ],
  "dependencies": [
    "framer-motion",
    "react"
  ],
  "files": [
    {
      "path": "@uitripled/react-shadcn/src/components/motion-core/synced-lyric-captions.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"framer-motion\";\nimport {\n  Pause,\n  Play,\n  Settings,\n  SkipBack,\n  SkipForward,\n  Volume2,\n  VolumeX,\n} from \"lucide-react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\ntype ScriptLine = {\n  time: number;\n  text: string;\n  speaker?: string;\n};\n\ntype SyncedLyricCaptionsProps = {\n  script?: ScriptLine[];\n  audioSrc?: string;\n  title?: string;\n};\n\nconst DEFAULT_SCRIPT: ScriptLine[] = [\n  {\n    time: 1,\n    text: \"Welcome to the enhanced caption experience.\",\n    speaker: \"Narrator\",\n  },\n  {\n    time: 5,\n    text: \"Now with improved controls and visuals.\",\n    speaker: \"Narrator\",\n  },\n  { time: 9, text: \"Skip forward or backward with ease.\", speaker: \"Narrator\" },\n  {\n    time: 13.5,\n    text: \"Adjust volume and playback speed.\",\n    speaker: \"Narrator\",\n  },\n  {\n    time: 17,\n    text: \"Track your progress with precision.\",\n    speaker: \"Narrator\",\n  },\n  {\n    time: 21,\n    text: \"Experience smooth animations throughout.\",\n    speaker: \"Narrator\",\n  },\n];\n\nfunction formatSeconds(seconds: number) {\n  const safeSeconds = Math.max(0, seconds);\n  const mins = Math.floor(safeSeconds / 60);\n  const secs = Math.floor(safeSeconds % 60);\n  return `${mins}:${secs.toString().padStart(2, \"0\")}`;\n}\n\nexport function SyncedLyricCaptions({\n  script = DEFAULT_SCRIPT,\n  audioSrc,\n  title = \"Enhanced Synced Captions\",\n}: SyncedLyricCaptionsProps) {\n  const lineVariants = {\n    initial: { opacity: 0, y: 32, scale: 0.97, filter: \"blur(6px)\" },\n    animate: { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" },\n    exit: { opacity: 0, y: -14, scale: 0.97, filter: \"blur(6px)\" },\n  };\n\n  const sortedScript = useMemo(\n    () => [...script].sort((a, b) => a.time - b.time),\n    [script]\n  );\n\n  const fallbackDuration = useMemo(\n    () => (sortedScript.at(-1)?.time ?? 0) + 3,\n    [sortedScript]\n  );\n\n  const [currentTime, setCurrentTime] = useState(0);\n  const [duration, setDuration] = useState(fallbackDuration);\n  const [isPlaying, setIsPlaying] = useState(false);\n  const [volume, setVolume] = useState(1);\n  const [isMuted, setIsMuted] = useState(false);\n  const [playbackRate, setPlaybackRate] = useState(1);\n  const [showSettings, setShowSettings] = useState(false);\n\n  const audioRef = useRef<HTMLAudioElement | null>(null);\n  const rafRef = useRef<number | null>(null);\n  const lastRafTimestampRef = useRef<number | null>(null);\n\n  useEffect(() => {\n    setDuration(audioRef.current?.duration || fallbackDuration);\n  }, [fallbackDuration]);\n\n  useEffect(() => {\n    if (!audioSrc) return;\n\n    const audio = new Audio(audioSrc);\n    audioRef.current = audio;\n    audio.volume = volume;\n    audio.playbackRate = playbackRate;\n\n    const handleTimeUpdate = () => setCurrentTime(audio.currentTime);\n    const handleLoaded = () =>\n      setDuration(\n        Number.isFinite(audio.duration) ? audio.duration : fallbackDuration\n      );\n    const handleEnded = () => setIsPlaying(false);\n    const handlePause = () => setIsPlaying(false);\n    const handlePlay = () => setIsPlaying(true);\n\n    audio.addEventListener(\"timeupdate\", handleTimeUpdate);\n    audio.addEventListener(\"loadedmetadata\", handleLoaded);\n    audio.addEventListener(\"ended\", handleEnded);\n    audio.addEventListener(\"pause\", handlePause);\n    audio.addEventListener(\"play\", handlePlay);\n\n    return () => {\n      audio.pause();\n      audio.removeEventListener(\"timeupdate\", handleTimeUpdate);\n      audio.removeEventListener(\"loadedmetadata\", handleLoaded);\n      audio.removeEventListener(\"ended\", handleEnded);\n      audio.removeEventListener(\"pause\", handlePause);\n      audio.removeEventListener(\"play\", handlePlay);\n      audioRef.current = null;\n    };\n  }, [audioSrc, fallbackDuration]);\n\n  useEffect(() => {\n    if (audioRef.current) {\n      audioRef.current.volume = isMuted ? 0 : volume;\n    }\n  }, [volume, isMuted]);\n\n  useEffect(() => {\n    if (audioRef.current) {\n      audioRef.current.playbackRate = playbackRate;\n    }\n  }, [playbackRate]);\n\n  useEffect(() => {\n    if (audioRef.current || !isPlaying) return;\n\n    const tick = (timestamp: number) => {\n      const last = lastRafTimestampRef.current ?? timestamp;\n      const deltaSeconds = ((timestamp - last) / 1000) * playbackRate;\n      lastRafTimestampRef.current = timestamp;\n\n      setCurrentTime((prev) => {\n        const next = Math.min(prev + deltaSeconds, duration);\n        if (next >= duration) {\n          setIsPlaying(false);\n          return duration;\n        }\n        return next;\n      });\n\n      rafRef.current = requestAnimationFrame(tick);\n    };\n\n    rafRef.current = requestAnimationFrame(tick);\n\n    return () => {\n      if (rafRef.current) cancelAnimationFrame(rafRef.current);\n      lastRafTimestampRef.current = null;\n    };\n  }, [isPlaying, duration, playbackRate]);\n\n  const activeIndex = useMemo(() => {\n    let idx = -1;\n    for (let i = 0; i < sortedScript.length; i++) {\n      if (sortedScript[i].time <= currentTime + 0.01) {\n        idx = i;\n      } else {\n        break;\n      }\n    }\n    return idx;\n  }, [sortedScript, currentTime]);\n\n  const activeLine = activeIndex >= 0 ? sortedScript[activeIndex] : null;\n  const nextLine = sortedScript[activeIndex + 1];\n  const visibleLines = sortedScript\n    .filter((line) => line.time <= currentTime + 0.01)\n    .slice(-5);\n\n  const safeDuration = Math.max(duration, 0.001);\n  const progress = Math.min(1, currentTime / safeDuration);\n\n  const handlePlayPause = async () => {\n    const audio = audioRef.current;\n\n    if (audio) {\n      if (isPlaying) {\n        audio.pause();\n        return;\n      }\n      if (audio.ended || currentTime >= duration) {\n        audio.currentTime = 0;\n        setCurrentTime(0);\n      }\n      await audio.play();\n      return;\n    }\n\n    setCurrentTime((prev) => (prev >= duration ? 0 : prev));\n    lastRafTimestampRef.current = null;\n    setIsPlaying((prev) => !prev);\n  };\n\n  const handleRestart = () => {\n    setCurrentTime(0);\n    if (audioRef.current) {\n      audioRef.current.currentTime = 0;\n    }\n  };\n\n  const handleSkip = (seconds: number) => {\n    const newTime = Math.max(0, Math.min(currentTime + seconds, duration));\n    setCurrentTime(newTime);\n    if (audioRef.current) {\n      audioRef.current.currentTime = newTime;\n    }\n  };\n\n  const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {\n    const rect = e.currentTarget.getBoundingClientRect();\n    const x = e.clientX - rect.left;\n    const percent = x / rect.width;\n    const newTime = percent * duration;\n    setCurrentTime(newTime);\n    if (audioRef.current) {\n      audioRef.current.currentTime = newTime;\n    }\n  };\n\n  const handleLineClick = (time: number) => {\n    setCurrentTime(time);\n    if (audioRef.current) {\n      audioRef.current.currentTime = time;\n    }\n  };\n\n  const handleLineKeyDown = (time: number) => (e: React.KeyboardEvent) => {\n    if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      handleLineClick(time);\n    }\n  };\n\n  const toggleMute = () => {\n    setIsMuted(!isMuted);\n  };\n\n  const handleProgressKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const step = 2 / Math.max(playbackRate, 0.25);\n    if (e.key === \"ArrowRight\") {\n      e.preventDefault();\n      handleSkip(step);\n    }\n    if (e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      handleSkip(-step);\n    }\n    if (e.key === \"Home\") {\n      e.preventDefault();\n      handleRestart();\n    }\n    if (e.key === \"End\") {\n      e.preventDefault();\n      handleSkip(duration);\n    }\n  };\n\n  return (\n    <div className=\"w-full\">\n      <div className=\"rounded-2xl border border-border/60 bg-background/70 p-4 shadow-lg backdrop-blur-xl sm:p-6\">\n        {/* Header */}\n        <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between\">\n          <div className=\"space-y-1\">\n            <p className=\"text-xs uppercase tracking-[0.2em] text-muted-foreground\">\n              {title}\n            </p>\n            <div className=\"flex flex-wrap items-center gap-2 text-sm text-muted-foreground\">\n              <span>\n                {audioSrc ? \"Audio synced\" : \"Timer synced\"} ·{\" \"}\n                {sortedScript.length} lines · {playbackRate}x\n              </span>\n            </div>\n          </div>\n          <div className=\"flex flex-wrap items-center gap-2 sm:justify-end\">\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"h-10 w-10 rounded-full border border-border/60\"\n              onClick={() => setShowSettings(!showSettings)}\n              aria-label=\"Settings\"\n            >\n              <Settings className=\"h-4 w-4\" />\n            </Button>\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"h-9 w-9 rounded-full border border-border/60\"\n              onClick={toggleMute}\n              aria-label={isMuted ? \"Unmute\" : \"Mute\"}\n            >\n              {isMuted ? (\n                <VolumeX className=\"h-4 w-4\" />\n              ) : (\n                <Volume2 className=\"h-4 w-4\" />\n              )}\n            </Button>\n            {audioSrc && !isMuted && (\n              <input\n                type=\"range\"\n                min=\"0\"\n                max=\"1\"\n                step=\"0.01\"\n                value={volume}\n                onChange={(e) => setVolume(parseFloat(e.target.value))}\n                className=\"w-20 accent-primary\"\n                aria-label=\"Volume\"\n              />\n            )}\n          </div>\n        </div>\n\n        {/* Settings Panel */}\n        <AnimatePresence>\n          {showSettings && (\n            <motion.div\n              initial={{ height: 0, opacity: 0, marginTop: 0 }}\n              animate={{ height: \"auto\", opacity: 1, marginTop: 16 }}\n              exit={{ height: 0, opacity: 0, marginTop: 0 }}\n              className=\"overflow-hidden\"\n            >\n              <div className=\"rounded-xl border border-border/60 bg-muted/40 p-4\">\n                <div className=\"mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n                  Playback Speed\n                </div>\n                <div className=\"flex flex-wrap gap-2\">\n                  {[0.5, 0.75, 1, 1.25, 1.5, 2].map((rate) => (\n                    <button\n                      key={rate}\n                      onClick={() => setPlaybackRate(rate)}\n                      className={cn(\n                        \"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors\",\n                        playbackRate === rate\n                          ? \"bg-primary text-primary-foreground\"\n                          : \"bg-muted text-muted-foreground hover:bg-muted/80\"\n                      )}\n                    >\n                      {rate}x\n                    </button>\n                  ))}\n                </div>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        <div className=\"mt-6 h-px w-full bg-gradient-to-r from-transparent via-border to-transparent\" />\n\n        {/* Caption Display */}\n        <div>\n          <div className=\"relative z-10 flex h-full flex-col justify-end gap-2 p-4 sm:p-6 h-[280px] overflow-hidden rounded-xl border border-border/60 bg-gradient-to-b from-muted/40 via-background to-background sm:h-[320px] sm:h-[320px]\">\n            <AnimatePresence mode=\"popLayout\">\n              {visibleLines.map((line) => (\n                <motion.button\n                  key={line.time}\n                  layout\n                  variants={lineVariants}\n                  initial=\"initial\"\n                  animate=\"animate\"\n                  exit=\"exit\"\n                  transition={{\n                    type: \"spring\",\n                    stiffness: 240,\n                    damping: 28,\n                    mass: 0.9,\n                  }}\n                  type=\"button\"\n                  onClick={() => handleLineClick(line.time)}\n                  onKeyDown={handleLineKeyDown(line.time)}\n                  tabIndex={0}\n                  className={cn(\n                    \"text-left outline-none ring-offset-2 ring-offset-background focus-visible:ring-2 focus-visible:ring-primary/70\",\n                    \"cursor-pointer rounded-lg border border-border/60 bg-background/80 px-4 py-3 text-lg shadow-sm transition-all hover:bg-background/90 hover:shadow-md\",\n                    line.time === activeLine?.time\n                      ? \"border-primary/60 text-foreground shadow-md\"\n                      : \"text-muted-foreground\"\n                  )}\n                >\n                  <div className=\"flex items-center justify-between text-xs text-muted-foreground/80\">\n                    <span className=\"font-mono\">\n                      {formatSeconds(line.time)}\n                    </span>\n                    <div className=\"flex items-center gap-2\">\n                      {line.speaker && (\n                        <span className=\"rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold\">\n                          {line.speaker}\n                        </span>\n                      )}\n                      {line.time === activeLine?.time && (\n                        <motion.span\n                          initial={{ scale: 0 }}\n                          animate={{ scale: 1 }}\n                          className=\"inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary\"\n                        >\n                          <span className=\"h-1 w-1 animate-pulse rounded-full bg-primary\" />\n                          Live\n                        </motion.span>\n                      )}\n                    </div>\n                  </div>\n                  <p className=\"mt-1 leading-tight\" aria-live=\"polite\">\n                    {line.text}\n                  </p>\n                </motion.button>\n              ))}\n            </AnimatePresence>\n\n            {nextLine && (\n              <motion.div\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                className=\"flex items-center gap-2 text-xs text-muted-foreground\"\n              >\n                <div className=\"h-1 w-1 animate-pulse rounded-full bg-primary\" />\n                <span>\n                  Next at {formatSeconds(nextLine.time)}: {nextLine.text}\n                </span>\n              </motion.div>\n            )}\n          </div>\n        </div>\n\n        {/* Progress Bar */}\n        <div className=\"mt-6\">\n          <div className=\"flex items-center justify-between text-xs font-mono text-muted-foreground\">\n            <span>{formatSeconds(currentTime)}</span>\n            <span>{formatSeconds(duration)}</span>\n          </div>\n          <div\n            className=\"relative mt-2 h-2 w-full cursor-pointer overflow-hidden rounded-full bg-border/60\"\n            onClick={handleProgressClick}\n            onKeyDown={handleProgressKeyDown}\n            role=\"slider\"\n            aria-label=\"Playback progress\"\n            aria-valuemin={0}\n            aria-valuemax={Math.round(safeDuration)}\n            aria-valuenow={Math.round(currentTime)}\n            tabIndex={0}\n          >\n            <motion.div\n              className=\"absolute left-0 top-0 h-full bg-gradient-to-r from-primary via-primary/80 to-primary/60\"\n              style={{ width: `${progress * 100}%` }}\n              transition={{ type: \"tween\", ease: \"linear\", duration: 0.1 }}\n            />\n            <div className=\"pointer-events-none absolute inset-0\">\n              {sortedScript.map((line) => (\n                <motion.span\n                  key={line.time}\n                  className={cn(\n                    \"absolute top-0 h-full w-0.5 rounded-full\",\n                    line.time <= currentTime ? \"bg-primary\" : \"bg-border\"\n                  )}\n                  style={{\n                    left: `${Math.min(100, (line.time / safeDuration) * 100)}%`,\n                  }}\n                  animate={{\n                    scaleY: line.time === activeLine?.time ? 1.4 : 1,\n                  }}\n                  transition={{ type: \"spring\", stiffness: 300, damping: 20 }}\n                />\n              ))}\n            </div>\n          </div>\n        </div>\n\n        {/* Controls */}\n        <div className=\"mt-6\">\n          <div className=\"flex flex-wrap items-center justify-center gap-2 sm:gap-3\">\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"h-10 w-10 rounded-full border border-border/60\"\n              onClick={handleRestart}\n              aria-label=\"Restart\"\n            >\n              <SkipBack className=\"h-4 w-4\" />\n            </Button>\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"h-10 w-10 rounded-full border border-border/60\"\n              onClick={() => handleSkip(-5)}\n              aria-label=\"Skip back 5 seconds\"\n            >\n              <span className=\"text-xs font-semibold\">-5</span>\n            </Button>\n            <Button\n              onClick={handlePlayPause}\n              className=\"h-14 w-14 gap-2 rounded-full px-5\"\n              variant=\"default\"\n              aria-label={isPlaying ? \"Pause\" : \"Play\"}\n            >\n              {isPlaying ? (\n                <Pause className=\"h-6 w-6\" />\n              ) : (\n                <Play className=\"ml-0.5 h-6 w-6\" />\n              )}\n            </Button>\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"h-10 w-10 rounded-full border border-border/60\"\n              onClick={() => handleSkip(5)}\n              aria-label=\"Skip forward 5 seconds\"\n            >\n              <span className=\"text-xs font-semibold\">+5</span>\n            </Button>\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"h-10 w-10 rounded-full border border-border/60\"\n              onClick={() => handleSkip(10)}\n              aria-label=\"Skip forward 10 seconds\"\n            >\n              <SkipForward className=\"h-4 w-4\" />\n            </Button>\n          </div>\n\n          <div className=\"w-20\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/uitripled/synced-lyric-captions-shadcnui.tsx"
    }
  ]
}