{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"now-playing","type":"registry:component","title":"Now playing","description":"A listening-to pill whose record slides out from behind the cover and spins while it plays.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/now-playing.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Track = {\n  title: string;\n  artist: string;\n  // Two colours for the generated cover and the record's label: album art\n  // is data here, so raw colours are the point.\n  art: [string, string];\n};\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst SLIDE = { type: \"spring\", duration: 0.5, bounce: 0.15 } as const;\nconst SWAP = { duration: 0.28, ease: EASE_OUT } as const;\n\n// A record turns at 33 1/3 rpm, 1.8s a turn; the equalizer bars bounce at\n// three unrelated rates so they never fall into step.\nconst CSS = `\n@keyframes np-spin { to { transform: rotate(360deg) } }\n@keyframes np-eq { 0%, 100% { transform: scaleY(.3) } 50% { transform: scaleY(1) } }\n@keyframes np-marquee { 0%, 18% { transform: translateX(0) } 62%, 80% { transform: translateX(var(--np-shift)) } 100% { transform: translateX(0) } }\n`;\n\nexport function NowPlaying({\n  track,\n  playing,\n  onPlayingChange,\n  onSkip,\n  paused = false,\n  className,\n}: {\n  track: Track;\n  playing: boolean;\n  onPlayingChange: (playing: boolean) => void;\n  onSkip?: () => void;\n  // Freezes every loop, for idle previews.\n  paused?: boolean;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const still = paused || reduceMotion;\n  const moving = playing && !still;\n\n  return (\n    <div\n      className={cn(\n        \"group/np relative flex h-14 w-[min(320px,100%)] items-center rounded-full bg-background p-2 pr-3 shadow-raised\",\n        className,\n      )}\n    >\n      <style>{CSS}</style>\n      <button\n        type=\"button\"\n        onClick={() => onPlayingChange(!playing)}\n        aria-label={playing ? `Pause ${track.title}` : `Play ${track.title}`}\n        aria-pressed={playing}\n        className=\"absolute inset-0 rounded-full outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\"\n      />\n\n      {/* Cover in front, the record behind it. Room is kept for the record\n          whether it's out or tucked in, so the text never shifts. */}\n      <div className=\"pointer-events-none relative h-10 w-[64px] shrink-0\">\n        <motion.div\n          className=\"absolute top-0 left-0\"\n          initial={false}\n          // Playing slides the record out; paused it only peeks.\n          animate={{ x: playing ? 22 : 7 }}\n          transition={reduceMotion ? { duration: 0 } : SLIDE}\n        >\n          <Vinyl label={track.art[0]} spinning={moving} />\n        </motion.div>\n        <div className=\"absolute top-0 left-0 size-10 overflow-hidden rounded-[9px] shadow-[0_1px_3px_oklch(0_0_0/0.25)]\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.div\n              key={track.title}\n              className=\"absolute inset-0\"\n              initial={{ opacity: 0, scale: 1.08 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.15 } }}\n              transition={SWAP}\n            >\n              <Cover art={track.art} />\n            </motion.div>\n          </AnimatePresence>\n          {/* Play and pause show on hover, over the art. */}\n          <span className=\"absolute inset-0 grid place-items-center bg-black/35 text-white opacity-0 transition-opacity duration-150 ease-out group-hover/np:opacity-100\">\n            <svg\n              viewBox=\"0 0 16 16\"\n              className=\"size-4\"\n              fill=\"currentColor\"\n              aria-hidden\n            >\n              {playing ? (\n                <path d=\"M4.5 3h2.2v10H4.5zM9.3 3h2.2v10H9.3z\" />\n              ) : (\n                <path d=\"M5 3.2v9.6a.5.5 0 0 0 .76.43l7.6-4.8a.5.5 0 0 0 0-.86l-7.6-4.8A.5.5 0 0 0 5 3.2Z\" />\n              )}\n            </svg>\n          </span>\n        </div>\n      </div>\n\n      <div className=\"pointer-events-none ml-1 min-w-0 flex-1\">\n        <p className=\"text-[11px] leading-none font-medium tracking-wide text-muted-foreground uppercase\">\n          {playing ? \"Listening to\" : \"Paused\"}\n        </p>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.div\n            key={track.title}\n            initial={{ opacity: 0, y: 4, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={{\n              opacity: 0,\n              y: -4,\n              filter: \"blur(4px)\",\n              transition: { duration: 0.15 },\n            }}\n            transition={SWAP}\n            className=\"mt-1\"\n          >\n            <Marquee running={moving}>\n              <span className=\"text-[14px] font-medium text-foreground\">\n                {track.title}\n              </span>\n              <span className=\"text-[14px] text-muted-foreground\"> · {track.artist}</span>\n            </Marquee>\n          </motion.div>\n        </AnimatePresence>\n      </div>\n\n      {/* The equalizer and skip share one slot: bars at rest, skip on\n          hover, so a hidden button never eats the title's room. */}\n      <div className=\"relative ml-2 grid size-8 shrink-0 place-items-center\">\n        <span\n          className={cn(\n            \"transition-[opacity] duration-150 ease-out\",\n            onSkip &&\n              \"group-hover/np:opacity-0 group-has-[:focus-visible]/np:opacity-0\",\n          )}\n        >\n          <Equalizer moving={moving} playing={playing} />\n        </span>\n        {onSkip && (\n          // Above the pill's own button, so skipping never also pauses.\n          <button\n            type=\"button\"\n            aria-label=\"Next track\"\n            onClick={onSkip}\n            className=\"absolute inset-0 grid touch-manipulation place-items-center rounded-full text-muted-foreground opacity-0 outline-hidden transition-[opacity,color,scale] duration-150 ease-out group-hover/np:opacity-100 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n          >\n            <svg\n              viewBox=\"0 0 16 16\"\n              className=\"size-4\"\n              fill=\"currentColor\"\n              aria-hidden\n            >\n              <path d=\"M3.5 3.4v9.2a.5.5 0 0 0 .78.42L10.5 8.4a.5.5 0 0 0 0-.8L4.28 2.98a.5.5 0 0 0-.78.42ZM11.5 3h1.6v10h-1.6z\" />\n            </svg>\n          </button>\n        )}\n      </div>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {playing ? `Playing ${track.title} by ${track.artist}` : \"Paused\"}\n      </span>\n    </div>\n  );\n}\n\n// A black record in either theme: fine grooves, a coloured label with a\n// spindle hole, and a sheen that doesn't turn with it, so the spin reads as\n// light sliding across the grooves.\nfunction Vinyl({ label, spinning }: { label: string; spinning: boolean }) {\n  return (\n    <div className=\"relative size-10 rounded-full bg-[oklch(0.16_0_0)] shadow-[0_1px_3px_oklch(0_0_0/0.3),inset_0_0_0_1px_oklch(1_0_0/0.12)]\">\n      <div\n        className=\"absolute inset-0 rounded-full\"\n        style={{\n          background:\n            \"repeating-radial-gradient(circle at center, oklch(0.2 0 0) 0 0.6px, oklch(0.14 0 0) 0.6px 1.6px)\",\n          animation: \"np-spin 1.8s linear infinite\",\n          animationPlayState: spinning ? \"running\" : \"paused\",\n        }}\n      >\n        <div\n          className=\"absolute inset-[30%] rounded-full\"\n          style={{ background: label }}\n        >\n          {/* Off-centre print on the label, so the turn is visible. */}\n          <span className=\"absolute top-[18%] left-[42%] h-[22%] w-[16%] rounded-full bg-white/60\" />\n          <span className=\"absolute inset-[42%] rounded-full bg-[oklch(0.16_0_0)]\" />\n        </div>\n      </div>\n      <div className=\"pointer-events-none absolute inset-0 rounded-full bg-[conic-gradient(from_30deg,transparent_0deg,oklch(1_0_0/0.14)_40deg,transparent_80deg,transparent_180deg,oklch(1_0_0/0.1)_220deg,transparent_260deg)]\" />\n    </div>\n  );\n}\n\n// Generated art: a sun over a horizon, in the track's two colours.\nfunction Cover({ art: [a, b] }: { art: [string, string] }) {\n  return (\n    <svg viewBox=\"0 0 40 40\" className=\"size-full\" aria-hidden>\n      <rect width=\"40\" height=\"40\" fill={b} />\n      <circle cx=\"20\" cy=\"22\" r=\"11\" fill={a} />\n      <rect y=\"24\" width=\"40\" height=\"16\" fill={b} />\n      <path\n        d=\"M0 27h40M0 31h40M0 35h40\"\n        stroke={a}\n        strokeWidth=\"1.4\"\n        opacity=\"0.7\"\n      />\n    </svg>\n  );\n}\n\nfunction Equalizer({ moving, playing }: { moving: boolean; playing: boolean }) {\n  return (\n    <span\n      aria-hidden\n      className=\"pointer-events-none flex h-4 items-end gap-[3px]\"\n    >\n      {[0.9, 0.7, 1.1].map((d, i) => (\n        <span\n          key={i}\n          className=\"w-[3px] origin-bottom rounded-full bg-foreground transition-[transform,opacity] duration-300 ease-out\"\n          style={{\n            height: \"100%\",\n            animation: moving\n              ? `np-eq ${d}s ease-in-out ${i * -0.3}s infinite`\n              : \"none\",\n            // Paused, the bars settle low and dim instead of freezing mid-hop.\n            transform: moving ? undefined : `scaleY(${[0.35, 0.55, 0.3][i]})`,\n            opacity: playing ? 1 : 0.4,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n\n// Scrolls a title that doesn't fit once to show its end, holds, and glides\n// back, then rests. Short titles never move.\nfunction Marquee({\n  running,\n  children,\n}: {\n  running: boolean;\n  children: React.ReactNode;\n}) {\n  const outer = useRef<HTMLDivElement>(null);\n  const inner = useRef<HTMLDivElement>(null);\n  const [shift, setShift] = useState(0);\n\n  useLayoutEffect(() => {\n    const o = outer.current;\n    const i = inner.current;\n    if (!o || !i) return;\n    const measure = () => setShift(Math.max(0, i.scrollWidth - o.clientWidth));\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(o);\n    return () => ro.disconnect();\n  }, []);\n\n  return (\n    <div\n      ref={outer}\n      className={cn(\n        \"overflow-hidden\",\n        shift > 0 &&\n          \"[mask-image:linear-gradient(to_right,black_calc(100%-16px),transparent)]\",\n      )}\n    >\n      <div\n        ref={inner}\n        className=\"w-max whitespace-nowrap\"\n        style={\n          {\n            \"--np-shift\": `${-shift}px`,\n            animation:\n              running && shift > 0\n                ? `np-marquee ${4 + shift / 30}s cubic-bezier(0.65,0,0.35,1) 1.2s 1 both`\n                : \"none\",\n          } as React.CSSProperties\n        }\n      >\n        {children}\n      </div>\n    </div>\n  );\n}\n\nconst TRACKS: Track[] = [\n  { title: \"Night Drive\", artist: \"Neon Coast\", art: [\"#ff7a59\", \"#2b2d6e\"] },\n  { title: \"Glass Rooms\", artist: \"Mira Sol\", art: [\"#f4d35e\", \"#0d3b3e\"] },\n  {\n    title: \"Everything We Built Under the Summer Light\",\n    artist: \"The Hollow Hours\",\n    art: [\"#9ad1d4\", \"#3a2e4f\"],\n  },\n];\n\nexport default function NowPlayingDemo() {\n  const play = usePreviewPlay();\n  const [playing, setPlaying] = useState(true);\n  const [index, setIndex] = useState(0);\n\n  return (\n    <NowPlaying\n      track={TRACKS[index]}\n      playing={playing}\n      onPlayingChange={setPlaying}\n      onSkip={() => {\n        setIndex((i) => (i + 1) % TRACKS.length);\n        setPlaying(true);\n      }}\n      paused={play === false}\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/now-playing"}