{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"cassette-scrubber","type":"registry:component","title":"Cassette scrubber","description":"Scrub audio by dragging the tape, with reels that spin like the real thing.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/cassette-scrubber.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// Drawing space for the cassette; the SVG scales with its container.\nconst VB_W = 440;\nconst VB_H = 280;\nconst LEFT = { x: 150, y: 140 };\nconst RIGHT = { x: 290, y: 140 };\n// Hub radius, and the radius of a full pack. 58 keeps a full pack inside\n// the 120 unit tall window.\nconst HUB = 22;\nconst FULL = 58;\n// Tape guide posts at the bottom corners, where the tape leaves the packs.\nconst POST_L = { x: 96, y: 226 };\nconst POST_R = { x: 344, y: 226 };\nconst WINDOW = { x: 78, y: 80, w: 284, h: 120, r: 20 };\n// Linear tape speed while playing: an empty hub turns half a turn a second,\n// close to a real deck, so a full pack visibly crawls and an empty hub races.\nconst TAPE_SPEED = HUB * Math.PI;\n// Dragging across the whole window moves this many seconds of tape. True\n// tape speed would be about 3s per drag, too slow to be a useful scrubber.\nconst SCRUB_SPAN = 40;\n// Time constants in seconds: the motor spins up quickly, a flick coasts a\n// little longer before friction settles it back to play speed or rest.\nconst SPIN_UP = 0.12;\nconst COAST = 0.35;\n// Critically damped seek spring (c = 2 * sqrt(k)), so arrow keys glide to\n// the new spot in about half a second without overshooting.\nconst SEEK_K = 140;\nconst SEEK_C = 2 * Math.sqrt(SEEK_K);\nconst STEP = 5;\n// A pointer that stood still this long before release has no momentum left.\nconst STILL_MS = 80;\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n\n// The cassette is a physical object, so it keeps its own materials in both\n// themes: a smoke-grey shell, a cream paper label with two printed stripes,\n// brown-black magnetic tape and white plastic hubs.\nconst SHELL = \"oklch(0.27 0.008 260)\";\nconst SHELL_TOP = \"oklch(0.33 0.008 260)\";\nconst SHELL_DEEP = \"oklch(0.2 0.008 260)\";\nconst SMOKE = \"oklch(0.17 0.006 260)\";\nconst TAPE = \"oklch(0.36 0.045 45)\";\nconst HUB_WHITE = \"oklch(0.95 0.005 90)\";\nconst LABEL = \"oklch(0.95 0.02 85)\";\nconst INK = \"oklch(0.24 0.02 60)\";\nconst INK_SOFT = \"oklch(0.5 0.02 60)\";\nconst STRIPE_A = \"oklch(0.72 0.15 60)\";\nconst STRIPE_B = \"oklch(0.6 0.19 32)\";\nconst METAL = \"oklch(0.72 0.005 260)\";\n\ntype Mode = \"free\" | \"drag\" | \"seek\";\ntype Point = { x: number; y: number };\n\nconst clamp = (v: number, lo = 0, hi = 1) => Math.min(Math.max(v, lo), hi);\n\n// Tape packs keep a constant total area, so radius goes with sqrt of length.\nconst packRadius = (share: number) =>\n  Math.sqrt(HUB * HUB + share * (FULL * FULL - HUB * HUB));\n\n// Where a line from an outside point touches a circle.\nfunction tangent(c: Point, r: number, p: Point, side: 1 | -1) {\n  const d = Math.hypot(p.x - c.x, p.y - c.y);\n  const t =\n    Math.atan2(p.y - c.y, p.x - c.x) + side * Math.acos(Math.min(r / d, 1));\n  return `${(c.x + r * Math.cos(t)).toFixed(2)} ${(c.y + r * Math.sin(t)).toFixed(2)}`;\n}\n\nfunction tapePath(rl: number, rr: number) {\n  return `M${tangent(LEFT, rl, POST_L, 1)}L${POST_L.x} ${POST_L.y}M${POST_R.x} ${POST_R.y}L${tangent(RIGHT, rr, POST_R, -1)}`;\n}\n\nfunction roundedRect(x: number, y: number, w: number, h: number, r: number) {\n  return `M${x + r} ${y}H${x + w - r}A${r} ${r} 0 0 1 ${x + w} ${y + r}V${y + h - r}A${r} ${r} 0 0 1 ${x + w - r} ${y + h}H${x + r}A${r} ${r} 0 0 1 ${x} ${y + h - r}V${y + r}A${r} ${r} 0 0 1 ${x + r} ${y}Z`;\n}\n\nconst WINDOW_PATH = roundedRect(\n  WINDOW.x,\n  WINDOW.y,\n  WINDOW.w,\n  WINDOW.h,\n  WINDOW.r,\n);\n\nconst clock = (s: number) => {\n  const t = Math.floor(s);\n  return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, \"0\")}`;\n};\n\ntype Parts = {\n  packL?: SVGCircleElement | null;\n  packR?: SVGCircleElement | null;\n  edgeL?: SVGCircleElement | null;\n  edgeR?: SVGCircleElement | null;\n  reelL?: SVGGElement | null;\n  reelR?: SVGGElement | null;\n  blurL?: SVGCircleElement | null;\n  blurR?: SVGCircleElement | null;\n  tape?: SVGPathElement | null;\n};\n\nexport function CassetteScrubber({\n  duration,\n  title,\n  side = \"A\",\n  initialTime = 0,\n  onTimeChange,\n  className,\n}: {\n  /** Length of the track in seconds. */\n  duration: number;\n  title: string;\n  side?: string;\n  initialTime?: number;\n  /** Called whenever the whole second changes. */\n  onTimeChange?: (seconds: number) => void;\n  className?: string;\n}) {\n  const reduceMotion = !!useReducedMotion();\n  const clipId = useId();\n  const [playing, setPlaying] = useState(false);\n\n  const parts = useRef<Parts>({});\n  const readout = useRef<HTMLSpanElement>(null);\n  const slider = useRef<HTMLDivElement>(null);\n  const start = clamp(initialTime / duration);\n  const sim = useRef({\n    pos: start,\n    lastPos: start,\n    vel: 0,\n    mode: \"free\" as Mode,\n    target: 0,\n    angleL: 0,\n    angleR: 0,\n    blur: 0,\n    second: -1,\n  });\n  const playingRef = useRef(false);\n  const reduceRef = useRef(reduceMotion);\n  const onTimeRef = useRef(onTimeChange);\n  const drag = useRef<{ id: number; x: number; t: number } | null>(null);\n  const frame = useRef(0);\n  const last = useRef(0);\n\n  useEffect(() => {\n    reduceRef.current = reduceMotion;\n    onTimeRef.current = onTimeChange;\n  });\n\n  // Every frame writes straight to the DOM; React only re-renders when the\n  // play state flips.\n  const paint = useCallback(() => {\n    const s = sim.current;\n    const p = parts.current;\n    const rl = packRadius(1 - s.pos);\n    const rr = packRadius(s.pos);\n    p.packL?.setAttribute(\"r\", rl.toFixed(2));\n    p.packR?.setAttribute(\"r\", rr.toFixed(2));\n    p.edgeL?.setAttribute(\"r\", Math.max(rl - 3, HUB).toFixed(2));\n    p.edgeR?.setAttribute(\"r\", Math.max(rr - 3, HUB).toFixed(2));\n    p.tape?.setAttribute(\"d\", tapePath(rl, rr));\n    p.reelL?.setAttribute(\n      \"transform\",\n      `rotate(${s.angleL.toFixed(2)} ${LEFT.x} ${LEFT.y})`,\n    );\n    p.reelR?.setAttribute(\n      \"transform\",\n      `rotate(${s.angleR.toFixed(2)} ${RIGHT.x} ${RIGHT.y})`,\n    );\n    // Spun fast, the hub cutouts would strobe backwards, so they fade into\n    // a smeared ring instead, like motion blur on a real spool.\n    const solid = (1 - s.blur * 0.8).toFixed(3);\n    const smear = (s.blur * 0.5).toFixed(3);\n    p.reelL?.setAttribute(\"opacity\", solid);\n    p.reelR?.setAttribute(\"opacity\", solid);\n    p.blurL?.setAttribute(\"opacity\", smear);\n    p.blurR?.setAttribute(\"opacity\", smear);\n\n    const second = Math.floor(s.pos * duration + 1e-6);\n    if (second !== s.second) {\n      s.second = second;\n      if (readout.current) readout.current.textContent = clock(second);\n      slider.current?.setAttribute(\"aria-valuenow\", String(second));\n      slider.current?.setAttribute(\n        \"aria-valuetext\",\n        `${clock(second)} of ${clock(duration)}`,\n      );\n      onTimeRef.current?.(second);\n    }\n  }, [duration]);\n\n  const step = useCallback(\n    function tick(now: number) {\n      const s = sim.current;\n      // Clamp long gaps (a background tab) so nothing leaps on return.\n      const dt = Math.min((now - last.current) / 1000, 1 / 20);\n      last.current = now;\n      const drive = playingRef.current ? 1 / duration : 0;\n\n      if (s.mode === \"seek\") {\n        s.target = clamp(s.target + drive * dt);\n        const a = SEEK_K * (s.target - s.pos) - SEEK_C * (s.vel - drive);\n        s.vel += a * dt;\n        s.pos += s.vel * dt;\n        if (\n          Math.abs(s.target - s.pos) < 0.2 / duration &&\n          Math.abs(s.vel - drive) < 0.5 / duration\n        ) {\n          s.pos = s.target;\n          s.vel = drive;\n          s.mode = \"free\";\n        }\n      } else if (s.mode === \"free\") {\n        const tau = Math.abs(s.vel) > Math.abs(drive) ? COAST : SPIN_UP;\n        s.vel = drive + (s.vel - drive) * Math.exp(-dt / tau);\n        s.pos += s.vel * dt;\n      }\n\n      if (s.pos >= 1 || s.pos <= 0) {\n        s.pos = clamp(s.pos);\n        if (s.mode === \"free\") s.vel = 0;\n        if (s.pos >= 1 && playingRef.current && s.mode === \"free\") {\n          playingRef.current = false;\n          setPlaying(false);\n        }\n      }\n\n      // Reels turn by the length of tape that moved over their own radius,\n      // so tape speed is constant and the smaller pack always spins faster.\n      const moved = (s.pos - s.lastPos) * duration * TAPE_SPEED;\n      s.lastPos = s.pos;\n      if (!reduceRef.current) {\n        const dl = (-moved / packRadius(1 - s.pos)) * (180 / Math.PI);\n        const dr = (-moved / packRadius(s.pos)) * (180 / Math.PI);\n        s.angleL = (s.angleL + dl) % 360;\n        s.angleR = (s.angleR + dr) % 360;\n        const spin = dt > 0 ? Math.max(Math.abs(dl), Math.abs(dr)) / dt : 0;\n        // Six cutouts start to alias around 1080 deg/s at 60fps.\n        const goal = clamp((spin - 1080) / 1080);\n        s.blur += (goal - s.blur) * Math.min(dt * 12, 1);\n      }\n      paint();\n\n      if (\n        s.mode === \"free\" &&\n        !playingRef.current &&\n        // Slower than 1/50th of play speed is invisible; let it sleep.\n        Math.abs(s.vel) * duration < 0.02 &&\n        s.blur < 0.01\n      ) {\n        s.vel = 0;\n        s.blur = 0;\n        paint();\n        frame.current = 0;\n        return;\n      }\n      frame.current = requestAnimationFrame(tick);\n    },\n    [duration, paint],\n  );\n\n  const wake = useCallback(() => {\n    if (frame.current) return;\n    last.current = performance.now();\n    frame.current = requestAnimationFrame(step);\n  }, [step]);\n\n  useEffect(() => {\n    paint();\n    return () => {\n      cancelAnimationFrame(frame.current);\n      frame.current = 0;\n    };\n  }, [paint]);\n\n  const togglePlay = () => {\n    const s = sim.current;\n    const next = !playingRef.current;\n    playingRef.current = next;\n    setPlaying(next);\n    // Playing from the very end rewinds first, and the reels show it.\n    if (next && s.pos >= 1) {\n      if (reduceRef.current) {\n        s.pos = 0;\n        s.lastPos = 0;\n      } else {\n        s.mode = \"seek\";\n        s.target = 0;\n      }\n    }\n    wake();\n  };\n\n  const seekTo = (to: number) => {\n    const s = sim.current;\n    if (reduceRef.current) {\n      s.pos = to;\n      s.lastPos = to;\n      s.mode = \"free\";\n      paint();\n      wake();\n      return;\n    }\n    s.mode = \"seek\";\n    s.target = to;\n    wake();\n  };\n\n  const endDrag = (e: React.PointerEvent) => {\n    const d = drag.current;\n    if (!d || d.id !== e.pointerId) return;\n    drag.current = null;\n    const s = sim.current;\n    if (reduceRef.current || e.timeStamp - d.t > STILL_MS) s.vel = 0;\n    s.mode = \"free\";\n    wake();\n  };\n\n  return (\n    <div\n      className={cn(\n        \"flex w-[min(440px,100%)] flex-col gap-4 select-none\",\n        className,\n      )}\n    >\n      <div className=\"relative\">\n        <svg\n          viewBox={`0 0 ${VB_W} ${VB_H}`}\n          className=\"block h-auto w-full\"\n          aria-hidden\n        >\n          <defs>\n            <clipPath id={clipId}>\n              <path d={WINDOW_PATH} />\n            </clipPath>\n            <linearGradient id={`${clipId}-shell`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n              <stop offset=\"0\" stopColor={SHELL_TOP} />\n              <stop offset=\"1\" stopColor={SHELL} />\n            </linearGradient>\n          </defs>\n          {/* The shell, with a faint bevel where the light catches its rim. */}\n          <rect x={8} y={8} width={424} height={264} rx={18} fill={`url(#${clipId}-shell)`} />\n          <rect x={9} y={9} width={422} height={262} rx={17} fill=\"none\" stroke=\"oklch(1 0 0 / 0.1)\" />\n          <path d={WINDOW_PATH} fill={SMOKE} />\n          <g clipPath={`url(#${clipId})`}>\n            {[LEFT, RIGHT].map((c, i) => (\n              <g key={i}>\n                <circle\n                  ref={(el) => {\n                    parts.current[i ? \"packR\" : \"packL\"] = el;\n                  }}\n                  cx={c.x}\n                  cy={c.y}\n                  r={packRadius(i ? start : 1 - start)}\n                  fill={TAPE}\n                />\n                {/* The outermost wraps catch a little light. */}\n                <circle\n                  ref={(el) => {\n                    parts.current[i ? \"edgeR\" : \"edgeL\"] = el;\n                  }}\n                  cx={c.x}\n                  cy={c.y}\n                  r={Math.max(packRadius(i ? start : 1 - start) - 3, HUB)}\n                  fill=\"none\"\n                  stroke=\"oklch(1 0 0 / 0.14)\"\n                />\n              </g>\n            ))}\n          </g>\n          <path\n            ref={(el) => {\n              parts.current.tape = el;\n            }}\n            d={tapePath(packRadius(1 - start), packRadius(start))}\n            fill=\"none\"\n            stroke={TAPE}\n            strokeWidth={2}\n          />\n          {[LEFT, RIGHT].map((c, i) => (\n            <g key={i}>\n              <g\n                ref={(el) => {\n                  parts.current[i ? \"reelR\" : \"reelL\"] = el;\n                }}\n              >\n                {/* White plastic hub: three windows, and a toothed socket\n                    for the deck's spindle. */}\n                <circle cx={c.x} cy={c.y} r={HUB - 1} fill={HUB_WHITE} stroke=\"oklch(0 0 0 / 0.15)\" />\n                {[0, 120, 240].map((a) => (\n                  <circle\n                    key={a}\n                    // Rounded so server and browser trig agree on hydration.\n                    cx={Math.round(c.x + 14 * Math.cos((a * Math.PI) / 180))}\n                    cy={Math.round(c.y + 14 * Math.sin((a * Math.PI) / 180))}\n                    r={3.5}\n                    fill={TAPE}\n                  />\n                ))}\n                <circle cx={c.x} cy={c.y} r={8} fill={SHELL} />\n                {[0, 60, 120, 180, 240, 300].map((a) => (\n                  <rect\n                    key={a}\n                    x={c.x - 1.25}\n                    y={c.y - 8}\n                    width={2.5}\n                    height={3.5}\n                    rx={0.5}\n                    transform={`rotate(${a} ${c.x} ${c.y})`}\n                    fill={HUB_WHITE}\n                  />\n                ))}\n              </g>\n              <circle\n                ref={(el) => {\n                  parts.current[i ? \"blurR\" : \"blurL\"] = el;\n                }}\n                cx={c.x}\n                cy={c.y}\n                r={14}\n                fill=\"none\"\n                opacity={0}\n                stroke=\"oklch(0.6 0.02 60)\"\n                strokeWidth={7}\n              />\n            </g>\n          ))}\n          {/* Glare on the smoked window, so it reads as plastic you look\n              through rather than a hole. */}\n          <path\n            d={`M${WINDOW.x + 150} ${WINDOW.y} L${WINDOW.x + 196} ${WINDOW.y} L${WINDOW.x + 120} ${WINDOW.y + WINDOW.h} L${WINDOW.x + 74} ${WINDOW.y + WINDOW.h}Z`}\n            clipPath={`url(#${clipId})`}\n            fill=\"oklch(1 0 0 / 0.06)\"\n            className=\"pointer-events-none\"\n          />\n          {/* The paper label, with the window cut out of it. */}\n          <path\n            d={`${roundedRect(30, 22, 380, 192, 10)}${WINDOW_PATH}`}\n            fillRule=\"evenodd\"\n            fill={LABEL}\n          />\n          <path\n            d={WINDOW_PATH}\n            fill=\"none\"\n            stroke=\"oklch(0 0 0 / 0.25)\"\n            strokeWidth={1.5}\n          />\n          <text x={48} y={57} fill={INK} className=\"font-sans\" fontSize={22} fontWeight={700}>\n            {side}\n          </text>\n          <text x={76} y={56} fill={INK} className=\"font-sans\" fontSize={16} fontWeight={500}>\n            {title}\n          </text>\n          <text x={392} y={56} textAnchor=\"end\" fill={INK_SOFT} className=\"font-mono\" fontSize={12}>\n            {clock(duration)}\n          </text>\n          {/* Two printed stripes run under the title, stopping short of\n              the window. */}\n          <rect x={30} y={67} width={380} height={4} fill={STRIPE_A} />\n          <rect x={30} y={71} width={380} height={4} fill={STRIPE_B} />\n          {/* Head opening, with the tape running across it. */}\n          <path d=\"M112 272 L132 222 H308 L328 272\" fill={SHELL_DEEP} stroke=\"oklch(1 0 0 / 0.08)\" />\n          <line\n            x1={POST_L.x}\n            x2={POST_R.x}\n            y1={POST_L.y}\n            y2={POST_R.y}\n            stroke={TAPE}\n            strokeWidth={2}\n          />\n          {[POST_L, POST_R].map((p) => (\n            <circle key={p.x} cx={p.x} cy={p.y + 4} r={4} fill={METAL} stroke=\"oklch(0 0 0 / 0.3)\" />\n          ))}\n          {[168, 272].map((x) => (\n            <circle key={x} cx={x} cy={252} r={6} fill=\"oklch(0.12 0 0)\" stroke=\"oklch(1 0 0 / 0.08)\" />\n          ))}\n          {[\n            [20, 20],\n            [420, 20],\n            [20, 260],\n            [420, 260],\n            [220, 250],\n          ].map(([x, y]) => (\n            <g key={`${x}-${y}`}>\n              <circle cx={x} cy={y} r={4} fill={METAL} stroke=\"oklch(0 0 0 / 0.35)\" />\n              <path d={`M${x - 2} ${y}h4M${x} ${y - 2}v4`} stroke=\"oklch(0.35 0 0)\" />\n            </g>\n          ))}\n        </svg>\n        <div\n          ref={slider}\n          role=\"slider\"\n          tabIndex={0}\n          aria-label={`Playback position, ${title}`}\n          aria-valuemin={0}\n          aria-valuemax={Math.floor(duration)}\n          // Seeded once for the server render; the frame loop owns it after.\n          aria-valuenow={Math.floor(start * duration)}\n          aria-valuetext={`${clock(start * duration)} of ${clock(duration)}`}\n          className=\"absolute cursor-grab touch-none rounded-[7%/16%] outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:cursor-grabbing\"\n          style={{\n            left: `${(WINDOW.x / VB_W) * 100}%`,\n            top: `${(WINDOW.y / VB_H) * 100}%`,\n            width: `${(WINDOW.w / VB_W) * 100}%`,\n            height: `${(WINDOW.h / VB_H) * 100}%`,\n          }}\n          onPointerDown={(e) => {\n            if (e.button !== 0 || drag.current) return;\n            e.currentTarget.setPointerCapture(e.pointerId);\n            drag.current = { id: e.pointerId, x: e.clientX, t: e.timeStamp };\n            const s = sim.current;\n            s.mode = \"drag\";\n            s.vel = 0;\n            wake();\n          }}\n          onPointerMove={(e) => {\n            const d = drag.current;\n            if (!d || d.id !== e.pointerId) return;\n            const s = sim.current;\n            const width = e.currentTarget.getBoundingClientRect().width || 1;\n            const next = clamp(\n              s.pos + ((e.clientX - d.x) / width) * (SCRUB_SPAN / duration),\n            );\n            const dt = (e.timeStamp - d.t) / 1000;\n            // Smoothed, so one jittery event doesn't decide the flick.\n            if (dt > 0) s.vel = s.vel * 0.5 + ((next - s.pos) / dt) * 0.5;\n            s.pos = next;\n            d.x = e.clientX;\n            d.t = e.timeStamp;\n          }}\n          onPointerUp={endDrag}\n          onPointerCancel={endDrag}\n          onKeyDown={(e) => {\n            if (e.key === \" \" || e.key === \"k\") {\n              e.preventDefault();\n              togglePlay();\n              return;\n            }\n            const s = sim.current;\n            const from = s.mode === \"seek\" ? s.target : s.pos;\n            const by = (seconds: number) => clamp(from + seconds / duration);\n            const to = {\n              ArrowRight: by(STEP),\n              ArrowUp: by(STEP),\n              ArrowLeft: by(-STEP),\n              ArrowDown: by(-STEP),\n              PageUp: by(STEP * 6),\n              PageDown: by(-STEP * 6),\n              Home: 0,\n              End: 1,\n            }[e.key];\n            if (to === undefined) return;\n            e.preventDefault();\n            seekTo(to);\n          }}\n        />\n      </div>\n      <div className=\"flex items-center gap-3 px-1\">\n        <button\n          type=\"button\"\n          aria-label={playing ? \"Pause\" : \"Play\"}\n          onClick={togglePlay}\n          className=\"relative flex size-11 shrink-0 items-center justify-center rounded-full bg-foreground text-background outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-none\"\n        >\n          <SwapIcon visible={!playing}>\n            {/* Drawn 1 unit right of center: a triangle's visual weight\n                sits left of its bounding box. */}\n            <path d=\"M9 6.5v11a.75.75 0 0 0 1.14.64l8.6-5.5a.75.75 0 0 0 0-1.28l-8.6-5.5A.75.75 0 0 0 9 6.5Z\" />\n          </SwapIcon>\n          <SwapIcon visible={playing}>\n            <rect x={7} y={6} width={3.5} height={12} rx={1} />\n            <rect x={13.5} y={6} width={3.5} height={12} rx={1} />\n          </SwapIcon>\n        </button>\n        <p className=\"font-mono text-[15px] text-foreground tabular-nums\">\n          <span ref={readout}>{clock(start * duration)}</span>\n          <span className=\"text-muted-foreground\"> / {clock(duration)}</span>\n        </p>\n        <p className=\"ml-auto text-right text-[13px] text-muted-foreground\">\n          Drag the reels to scrub\n        </p>\n      </div>\n    </div>\n  );\n}\n\nfunction SwapIcon({\n  visible,\n  children,\n}: {\n  visible: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <motion.svg\n      viewBox=\"0 0 24 24\"\n      className=\"absolute size-5\"\n      fill=\"currentColor\"\n      aria-hidden\n      initial={false}\n      animate={\n        visible\n          ? { scale: 1, opacity: 1, filter: \"blur(0px)\" }\n          : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" }\n      }\n      transition={ICON_SWAP}\n    >\n      {children}\n    </motion.svg>\n  );\n}\n\nexport default function CassetteScrubberDemo() {\n  return (\n    <CassetteScrubber title=\"Night Drive\" duration={214} initialTime={48} />\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"}],"categories":["objects"],"docs":"From ui lab: https://lab.xevrion.dev/lab/cassette-scrubber"}