{ "$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(null);\n const rafRef = useRef(null);\n const lastRafTimestampRef = useRef(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) => {\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) => {\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
\n
\n {/* Header */}\n
\n
\n

\n {title}\n

\n
\n \n {audioSrc ? \"Audio synced\" : \"Timer synced\"} ·{\" \"}\n {sortedScript.length} lines · {playbackRate}x\n \n
\n
\n
\n setShowSettings(!showSettings)}\n aria-label=\"Settings\"\n >\n \n \n \n {isMuted ? (\n \n ) : (\n \n )}\n \n {audioSrc && !isMuted && (\n setVolume(parseFloat(e.target.value))}\n className=\"w-20 accent-primary\"\n aria-label=\"Volume\"\n />\n )}\n
\n
\n\n {/* Settings Panel */}\n \n {showSettings && (\n \n
\n
\n Playback Speed\n
\n
\n {[0.5, 0.75, 1, 1.25, 1.5, 2].map((rate) => (\n 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 \n ))}\n
\n
\n \n )}\n
\n\n
\n\n {/* Caption Display */}\n
\n
\n \n {visibleLines.map((line) => (\n 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
\n \n {formatSeconds(line.time)}\n \n
\n {line.speaker && (\n \n {line.speaker}\n \n )}\n {line.time === activeLine?.time && (\n \n \n Live\n \n )}\n
\n
\n

\n {line.text}\n

\n \n ))}\n
\n\n {nextLine && (\n \n
\n \n Next at {formatSeconds(nextLine.time)}: {nextLine.text}\n \n \n )}\n
\n
\n\n {/* Progress Bar */}\n
\n
\n {formatSeconds(currentTime)}\n {formatSeconds(duration)}\n
\n \n \n
\n {sortedScript.map((line) => (\n \n ))}\n
\n
\n
\n\n {/* Controls */}\n
\n
\n \n \n \n handleSkip(-5)}\n aria-label=\"Skip back 5 seconds\"\n >\n -5\n \n \n {isPlaying ? (\n \n ) : (\n \n )}\n \n handleSkip(5)}\n aria-label=\"Skip forward 5 seconds\"\n >\n +5\n \n handleSkip(10)}\n aria-label=\"Skip forward 10 seconds\"\n >\n \n \n
\n\n
\n
\n
\n
\n );\n}\n", "type": "registry:component", "target": "components/uitripled/synced-lyric-captions-shadcnui.tsx" } ] }