{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "spotify-card", "title": "Spotify Card", "description": "A premium Spotify card with real-time metadata and morphing animations.", "dependencies": [ "framer-motion", "lucide-react", "cheerio", "got" ], "registryDependencies": [], "files": [ { "path": "registry/spark-ui/spotify/spotify-card.tsx", "content": "/* eslint-disable @next/next/no-img-element */\n\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { motion, useAnimation } from \"framer-motion\";\nimport { Loader2 } from \"lucide-react\";\nimport {\n forwardRef,\n useEffect,\n useImperativeHandle,\n useRef,\n useState,\n} from \"react\";\n\nexport interface SpotifyCardProps {\n trackUrl: string;\n className?: string;\n onPlayingChange?: (isPlaying: boolean) => void;\n}\n\nexport interface SpotifyCardRef {\n togglePlayback: () => void;\n}\n\nconst SpotifyLogo = ({\n className,\n isPlaying,\n}: {\n className?: string;\n isPlaying?: boolean;\n}) => (\n \n \n \n);\n\nexport const SpotifyCard = forwardRef(\n function SpotifyCard({ trackUrl, className, onPlayingChange }, ref) {\n const [isPlaying, setIsPlaying] = useState(false);\n const [mounted, setMounted] = useState(false);\n const [metadata, setMetadata] = useState<{\n title: string;\n artist: string;\n albumArt: string;\n previewUrl: string;\n } | null>(null);\n const [loading, setLoading] = useState(true);\n const [colors, setColors] = useState([\"#1DB954\", \"#191414\"]);\n const audioRef = useRef(null);\n const discControls = useAnimation();\n\n useEffect(() => {\n onPlayingChange?.(isPlaying);\n }, [isPlaying, onPlayingChange]);\n\n useEffect(() => {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- hydration gate\n setMounted(true);\n // The API accepts only a validated track ID (never a raw URL) to prevent\n // server-side request forgery.\n const trackId = trackUrl.match(/\\/track\\/([A-Za-z0-9]+)/)?.[1];\n const fetchMetadata = async () => {\n if (!trackId) {\n console.error(\"Spotify Card Error: invalid Spotify track URL\");\n setLoading(false);\n return;\n }\n setLoading(true);\n try {\n const response = await fetch(\n `/api/spotify/metadata?trackId=${encodeURIComponent(trackId)}`,\n );\n if (!response.ok) throw new Error(\"Failed to fetch\");\n const data = await response.json();\n setMetadata(data);\n\n // Extract palette from image\n if (data.albumArt) {\n const img = new Image();\n img.crossOrigin = \"Anonymous\";\n img.src = data.albumArt;\n img.onload = () => {\n const canvas = document.createElement(\"canvas\");\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n canvas.width = 10;\n canvas.height = 10;\n ctx.drawImage(img, 0, 0, 10, 10);\n const pixels = ctx.getImageData(0, 0, 10, 10).data;\n const uniqueColors = new Set();\n for (let i = 0; i < pixels.length; i += 12) {\n const r = pixels[i];\n const g = pixels[i + 1];\n const b = pixels[i + 2];\n uniqueColors.add(`rgb(${r}, ${g}, ${b})`);\n if (uniqueColors.size >= 3) break;\n }\n setColors(Array.from(uniqueColors));\n }\n };\n }\n\n // Cleanup previous audio if any\n if (audioRef.current) {\n audioRef.current.pause();\n audioRef.current = null;\n }\n\n if (data.previewUrl) {\n const audio = new Audio(data.previewUrl);\n audio.crossOrigin = \"anonymous\";\n audio.onended = () => {\n setIsPlaying(false);\n };\n audioRef.current = audio;\n } else {\n console.warn(\"No preview URL found for this track\");\n }\n } catch (err) {\n console.error(\"Spotify Card Error:\", err);\n } finally {\n setLoading(false);\n }\n };\n fetchMetadata();\n return () => {\n if (audioRef.current) {\n audioRef.current.pause();\n audioRef.current = null;\n }\n };\n }, [trackUrl]);\n\n useEffect(() => {\n if (isPlaying) {\n discControls.start({\n rotate: 360,\n transition: { duration: 4, repeat: Infinity, ease: \"linear\" },\n });\n } else {\n discControls.stop();\n discControls.start({\n rotate: 0,\n transition: { duration: 0.8, ease: \"backOut\" },\n });\n }\n }, [isPlaying, discControls]);\n\n const togglePlayback = () => {\n if (!audioRef.current) return;\n\n if (isPlaying) {\n audioRef.current.pause();\n setIsPlaying(false);\n } else {\n // Use a then/catch to handle potential play request interruptions\n audioRef.current\n .play()\n .then(() => {\n setIsPlaying(true);\n })\n .catch((err) => {\n console.warn(\"Playback interrupted:\", err);\n setIsPlaying(false);\n });\n }\n };\n\n useImperativeHandle(ref, () => ({ togglePlayback }), [isPlaying]);\n\n if (loading || !mounted) {\n return (\n \n \n \n );\n }\n\n const {\n title = \"Unknown Track\",\n artist = \"Unknown Artist\",\n albumArt = \"\",\n } = metadata || {};\n\n return (\n \n {/* Background Image Layer */}\n
\n \n \n \n\n {/* Subtle Overlay Wash */}\n
\n\n {/* Animated Blobs for depth */}\n \n \n\n {/* Noise Texture Overlay */}\n
\n
\n\n {/* Content */}\n
\n {/* Album Cover / Disc */}\n
\n \n \n \n {/* Vinyl Texture */}\n \n {/* Disc Center */}\n \n
\n \n \n \n
\n\n {/* Info Section */}\n
\n
\n

\n {title}\n

\n \n {artist}\n \n
\n
\n\n {/* Spotify Branding */}\n
\n \n
\n
\n
\n );\n },\n);\n", "type": "registry:component" }, { "path": "registry/spark-ui/spotify/metadata-route.ts", "content": "import * as cheerio from \"cheerio\";\nimport got from \"got\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\n// SSRF guard: the outbound request destination is always built server-side\n// from a validated Spotify track ID — never from a caller-supplied URL.\nconst TRACK_ID_RE = /^[A-Za-z0-9]{1,64}$/;\nconst MAX_RESPONSE_BYTES = 1_000_000;\n\nexport async function GET(req: NextRequest) {\n const { searchParams } = new URL(req.url);\n const trackId = searchParams.get(\"trackId\") ?? \"\";\n\n if (!TRACK_ID_RE.test(trackId)) {\n return NextResponse.json({ error: \"Invalid track ID\" }, { status: 400 });\n }\n\n try {\n const request = got(`https://open.spotify.com/track/${trackId}`, {\n headers: {\n \"user-agent\": \"Mozilla/5.0 (compatible; SparkUI-SpotifyCard/1.0)\",\n accept: \"text/html\",\n },\n followRedirect: false,\n retry: { limit: 0 },\n timeout: { request: 5000 },\n });\n request.on(\"downloadProgress\", ({ transferred }) => {\n if (transferred > MAX_RESPONSE_BYTES) request.cancel();\n });\n const { headers, body: html } = await request;\n\n if (!headers[\"content-type\"]?.includes(\"text/html\")) {\n return NextResponse.json(\n { error: \"Failed to fetch metadata\" },\n { status: 502 },\n );\n }\n\n const $ = cheerio.load(html);\n\n const title =\n $('meta[property=\"og:title\"]').attr(\"content\") || \"Unknown Track\";\n const image = $('meta[property=\"og:image\"]').attr(\"content\") || \"\";\n const previewUrl =\n $('meta[property=\"og:audio\"]').attr(\"content\") ||\n $('meta[name=\"twitter:audio:src\"]').attr(\"content\") ||\n \"\";\n\n // Spotify's og:description is often: \"Artist · Album · Song · Year\"\n const description =\n $('meta[property=\"og:description\"]').attr(\"content\") || \"\";\n // Usually the first part is the Artist\n const artist = description.split(\"·\")[0]?.trim() || \"Unknown Artist\";\n\n return NextResponse.json({\n title,\n artist,\n albumArt: image,\n previewUrl,\n });\n } catch {\n // Non-2xx, redirect attempt, timeout, or oversized response. Keep the\n // client response generic — no upstream details.\n return NextResponse.json(\n { error: \"Failed to fetch metadata\" },\n { status: 502 },\n );\n }\n}\n", "type": "registry:component", "target": "app/api/spotify/metadata/route.ts" } ], "type": "registry:component" }