{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "components-music-vertical-spotify-card-1",
  "title": "Vertical Spotify Card 1",
  "description": "A vertical Spotify music card with a progressive blur album art background, seek bar, and transport controls. Requires a /api/spotify route.",
  "dependencies": [
    "spotify-url-info"
  ],
  "files": [
    {
      "path": "registry/components/music/vertical-spotify-card-1/index.tsx",
      "content": "'use client';\n\nimport { useState, useEffect, useRef, SVGProps } from 'react';\nimport { cn } from '@/lib/utils';\n\ninterface SpotifyData {\n  title: string;\n  artist: string;\n  image: string;\n  link: string;\n  audio?: string;\n}\n\nexport interface VerticalSpotifyCard1Props {\n  url: string;\n  className?: string;\n  onPrev?: () => void;\n  onNext?: () => void;\n}\n\nfunction formatTime(seconds: number) {\n  if (!isFinite(seconds) || seconds < 0) return '0:00';\n  const m = Math.floor(seconds / 60);\n  const s = Math.floor(seconds % 60);\n  return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\nconst Spotify = (props: SVGProps<SVGSVGElement>) => (\n  <svg {...props} viewBox=\"0 0 256 256\" preserveAspectRatio=\"xMidYMid\">\n    <path\n      d=\"M128 0C57.308 0 0 57.309 0 128c0 70.696 57.309 128 128 128 70.697 0 128-57.304 128-128C256 57.314 198.697.007 127.998.007l.001-.006Zm58.699 184.614c-2.293 3.76-7.215 4.952-10.975 2.644-30.053-18.357-67.885-22.515-112.44-12.335a7.981 7.981 0 0 1-9.552-6.007 7.968 7.968 0 0 1 6-9.553c48.76-11.14 90.583-6.344 124.323 14.276 3.76 2.308 4.952 7.215 2.644 10.975Zm15.667-34.853c-2.89 4.695-9.034 6.178-13.726 3.289-34.406-21.148-86.853-27.273-127.548-14.92-5.278 1.594-10.852-1.38-12.454-6.649-1.59-5.278 1.386-10.842 6.655-12.446 46.485-14.106 104.275-7.273 143.787 17.007 4.692 2.89 6.175 9.034 3.286 13.72v-.001Zm1.345-36.293C162.457 88.964 94.394 86.71 55.007 98.666c-6.325 1.918-13.014-1.653-14.93-7.978-1.917-6.328 1.65-13.012 7.98-14.935C93.27 62.027 168.434 64.68 215.929 92.876c5.702 3.376 7.566 10.724 4.188 16.405-3.362 5.69-10.73 7.565-16.4 4.187h-.006Z\"\n      fill=\"#ffffff\"\n    />\n  </svg>\n);\n\nexport function VerticalSpotifyCard1({\n  url,\n  className,\n  onPrev,\n  onNext,\n}: VerticalSpotifyCard1Props) {\n  const [data, setData] = useState<SpotifyData | null>(null);\n  const [isLoading, setIsLoading] = useState(true);\n  const [error, setError] = useState(false);\n  const [isPlaying, setIsPlaying] = useState(false);\n  const [currentTime, setCurrentTime] = useState(0);\n  const [duration, setDuration] = useState(0);\n  const audioRef = useRef<HTMLAudioElement | null>(null);\n\n  useEffect(() => {\n    let cancelled = false;\n\n    if (audioRef.current) {\n      audioRef.current.pause();\n      audioRef.current = null;\n    }\n    setIsPlaying(false);\n    setCurrentTime(0);\n    setDuration(0);\n    setIsLoading(true);\n    setError(false);\n\n    fetch(`/api/spotify?url=${encodeURIComponent(url)}`)\n      .then((r) => (r.ok ? r.json() : Promise.reject()))\n      .then((d) => {\n        if (!cancelled) setData(d);\n      })\n      .catch(() => {\n        if (!cancelled) setError(true);\n      })\n      .finally(() => {\n        if (!cancelled) setIsLoading(false);\n      });\n\n    return () => {\n      cancelled = true;\n      audioRef.current?.pause();\n      audioRef.current = null;\n    };\n  }, [url]);\n\n  const handlePlayPause = () => {\n    if (!data?.audio) return;\n    if (!audioRef.current) {\n      audioRef.current = new Audio(data.audio);\n      audioRef.current.volume = 0.3;\n      audioRef.current.addEventListener('ended', () => {\n        setIsPlaying(false);\n        setCurrentTime(0);\n      });\n      audioRef.current.addEventListener('timeupdate', () => {\n        setCurrentTime(audioRef.current?.currentTime ?? 0);\n      });\n      audioRef.current.addEventListener('loadedmetadata', () => {\n        setDuration(audioRef.current?.duration ?? 0);\n      });\n    }\n    if (isPlaying) {\n      audioRef.current.pause();\n      setIsPlaying(false);\n    } else {\n      audioRef.current.play();\n      setIsPlaying(true);\n    }\n  };\n\n  const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const time = Number(e.target.value);\n    setCurrentTime(time);\n    if (audioRef.current) {\n      audioRef.current.currentTime = time;\n    }\n  };\n\n  if (isLoading) {\n    return (\n      <div\n        className={cn(\n          'relative h-130 w-84 overflow-hidden rounded-3xl bg-neutral-900',\n          className,\n        )}\n      >\n        <div className=\"absolute inset-0 animate-pulse bg-linear-to-b from-white/5 to-black/60\" />\n        <div className=\"absolute right-0 bottom-0 left-0 flex flex-col gap-4 p-6\">\n          <div className=\"h-5 w-44 rounded bg-white/10\" />\n          <div className=\"h-3.5 w-28 rounded bg-white/10\" />\n          <div className=\"mt-1 h-0.5 w-full rounded bg-white/10\" />\n          <div className=\"h-3 w-20 self-start rounded bg-white/10\" />\n          <div className=\"mt-1 flex items-center justify-center gap-7\">\n            <div className=\"size-8 rounded bg-white/10\" />\n            <div className=\"size-14 rounded-full bg-white/10\" />\n            <div className=\"size-8 rounded bg-white/10\" />\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  if (error || !data) {\n    return (\n      <div\n        className={cn(\n          'flex h-130 w-84 items-center justify-center rounded-3xl border border-border bg-muted/50 text-muted-foreground',\n          className,\n        )}\n      >\n        <p className=\"text-sm\">Failed to load Spotify data</p>\n      </div>\n    );\n  }\n\n  const seekProgress = duration ? (currentTime / duration) * 100 : 0;\n\n  return (\n    <div\n      className={cn(\n        'relative h-130 w-84 overflow-hidden rounded-3xl select-none',\n        className,\n      )}\n    >\n      {/* Base layer: blurred image */}\n      <img\n        src={data.image}\n        alt=\"\"\n        aria-hidden\n        className=\"absolute inset-0 h-full w-full object-cover\"\n        style={{ filter: 'blur(12px)', transform: 'scale(1.12)' }}\n      />\n\n      {/* Sharp top layer: fades out downward */}\n      <div className=\"absolute inset-0 overflow-hidden\">\n        <img\n          src={data.image}\n          alt=\"\"\n          aria-hidden\n          className=\"absolute inset-0 h-full w-full object-cover\"\n          style={{\n            WebkitMaskImage:\n              'linear-gradient(to bottom, black 55%, transparent 80%)',\n            maskImage: 'linear-gradient(to bottom, black 55%, transparent 80%)',\n          }}\n        />\n      </div>\n\n      {/* Gradient overlay */}\n      <div className=\"absolute inset-0 bg-linear-to-b from-black/10 via-black/10 to-black/80\" />\n\n      {/* Bottom vignette boost */}\n      <div className=\"absolute right-0 bottom-0 left-0 h-[60%] bg-linear-to-t from-black/65 to-transparent\" />\n\n      {/* Content */}\n      <div className=\"relative z-10 flex h-full flex-col\">\n        {/* Top bar */}\n        <div className=\"flex items-center justify-end p-4 pt-5\">\n          <a\n            href={data.link}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"text-white transition-colors hover:text-white/80\"\n            aria-label=\"Open in Spotify\"\n          >\n            <Spotify className=\"size-5\" />\n          </a>\n        </div>\n\n        <div className=\"flex-1\" />\n\n        {/* Bottom content */}\n        <div className=\"flex flex-col gap-3 px-6 pb-8\">\n          <div>\n            <h2 className=\"truncate text-[19px] leading-tight font-semibold tracking-[-0.022em] text-white\">\n              {data.title}\n            </h2>\n            <p className=\"mt-0.5 truncate text-[14px] tracking-[-0.01em] text-white/60\">\n              {data.artist}\n            </p>\n          </div>\n\n          {/* Seek bar */}\n          <div className=\"mt-0.5 flex flex-col gap-1.5\">\n            <input\n              type=\"range\"\n              className=\"spotify-seek w-full\"\n              min={0}\n              max={duration || 100}\n              step={0.05}\n              value={currentTime}\n              onChange={handleSeek}\n              disabled={!data.audio}\n              aria-label=\"Seek\"\n              style={{\n                background: `linear-gradient(to right, rgba(255,255,255,0.88) ${seekProgress}%, rgba(255,255,255,0.22) ${seekProgress}%)`,\n              }}\n            />\n            <div className=\"flex justify-between\">\n              <span className=\"font-mono text-[10px] text-white/45 tabular-nums\">\n                {formatTime(currentTime)}\n              </span>\n              <span className=\"font-mono text-[10px] text-white/45 tabular-nums\">\n                {formatTime(duration)}\n              </span>\n            </div>\n          </div>\n\n          {/* Transport controls */}\n          <div className=\"mt-0.5 flex items-center justify-center gap-2\">\n            <button\n              onClick={onPrev}\n              disabled={!onPrev}\n              aria-label=\"Previous\"\n              className=\"cursor-pointer rounded-full p-2 text-white/70 transition-all duration-150 hover:bg-white/10 hover:text-white active:scale-90 disabled:cursor-default disabled:opacity-30\"\n              style={{ filter: 'drop-shadow(0 0 4px rgba(255,255,255,0.35))' }}\n            >\n              <svg\n                width=\"20\"\n                height=\"20\"\n                viewBox=\"0 0 17 17\"\n                fill=\"currentColor\"\n              >\n                <path d=\"M15 2.5v12l-9-6 9-6z\" />\n                <rect x=\"1.5\" y=\"2.5\" width=\"2.8\" height=\"12\" rx=\"0.8\" />\n              </svg>\n            </button>\n\n            <button\n              onClick={handlePlayPause}\n              disabled={!data.audio}\n              aria-label={isPlaying ? 'Pause' : 'Play'}\n              className=\"cursor-pointer rounded-full p-2.5 text-white/90 transition-all duration-150 hover:bg-white/12 hover:text-white active:scale-95 disabled:cursor-default disabled:opacity-30\"\n              style={{ filter: 'drop-shadow(0 0 6px rgba(255,255,255,0.5))' }}\n            >\n              {isPlaying ? (\n                <svg\n                  width=\"24\"\n                  height=\"24\"\n                  viewBox=\"0 0 20 20\"\n                  fill=\"currentColor\"\n                >\n                  <rect x=\"4\" y=\"3\" width=\"4.5\" height=\"14\" rx=\"1.5\" />\n                  <rect x=\"11.5\" y=\"3\" width=\"4.5\" height=\"14\" rx=\"1.5\" />\n                </svg>\n              ) : (\n                <svg\n                  width=\"24\"\n                  height=\"24\"\n                  viewBox=\"0 0 20 20\"\n                  fill=\"currentColor\"\n                >\n                  <path d=\"M5 3.5v13l12-6.5L5 3.5z\" />\n                </svg>\n              )}\n            </button>\n\n            <button\n              onClick={onNext}\n              disabled={!onNext}\n              aria-label=\"Next\"\n              className=\"cursor-pointer rounded-full p-2 text-white/70 transition-all duration-150 hover:bg-white/10 hover:text-white active:scale-90 disabled:cursor-default disabled:opacity-30\"\n              style={{ filter: 'drop-shadow(0 0 4px rgba(255,255,255,0.35))' }}\n            >\n              <svg\n                width=\"20\"\n                height=\"20\"\n                viewBox=\"0 0 17 17\"\n                fill=\"currentColor\"\n              >\n                <path d=\"M2 2.5v12l9-6-9-6z\" />\n                <rect x=\"12.7\" y=\"2.5\" width=\"2.8\" height=\"12\" rx=\"0.8\" />\n              </svg>\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/odysseyui/vertical-spotify-card-1.tsx"
    }
  ],
  "css": {
    ".spotify-seek": {
      "-webkit-appearance": "none",
      "appearance": "none",
      "height": "4px",
      "border-radius": "4px",
      "outline": "none",
      "cursor": "pointer",
      "transition": "opacity 0.15s",
      "&:disabled": {
        "cursor": "default",
        "opacity": "0.4"
      },
      "&::-webkit-slider-thumb": {
        "-webkit-appearance": "none",
        "appearance": "none",
        "width": "10px",
        "height": "10px",
        "border-radius": "50%",
        "background": "rgba(255, 255, 255, 0.9)",
        "box-shadow": "0 0 6px rgba(255, 255, 255, 0.55)",
        "margin-top": "-4px",
        "transition": "transform 0.15s"
      },
      "&:not(:disabled):hover::-webkit-slider-thumb": {
        "transform": "scale(1.25)"
      },
      "&::-moz-range-thumb": {
        "width": "10px",
        "height": "10px",
        "border-radius": "50%",
        "background": "rgba(255, 255, 255, 0.9)",
        "border": "none",
        "box-shadow": "0 0 6px rgba(255, 255, 255, 0.55)"
      },
      "&::-moz-range-track": {
        "height": "2px",
        "border-radius": "1px"
      }
    },
    "@keyframes spin": {
      "from": {
        "transform": "rotate(0deg)"
      },
      "to": {
        "transform": "rotate(360deg)"
      }
    }
  },
  "type": "registry:component"
}