{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["controls","minimal"],"dependencies":["motion"],"description":"The tvOS scrubber — dragging the timeline raises a filmstrip of preview frames above the playhead, rendered live from a synthetic day-cycle video.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { SeekFilmstrip } from \"./seek-filmstrip\";\n\nconst Preview = () => (\n  <main className=\"flex h-dvh items-center justify-center overflow-hidden bg-[radial-gradient(ellipse_45%_70%_at_50%_-15%,rgb(147_197_253/0.16)_0%,transparent_70%),radial-gradient(ellipse_at_50%_50%,#0f1d3d_0%,#081330_50%,#030817_100%)] p-5\">\n    <SeekFilmstrip />\n  </main>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/seek-filmstrip/preview.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport type { FC } from \"react\";\nimport { AnimatePresence, MotionConfig, motion, useReducedMotion } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\n\n/**\n * Seek filmstrip — the tvOS scrub interaction. Grabbing the timeline pauses\n * playback and unfolds it into a fixed filmstrip; the scrub head glides over\n * it with a single preview frame floating above. The picture holds the\n * committed frame and only jumps on release. The \"video\" is a synthetic\n * day-cycle scene rendered as a pure function of time, so every preview\n * frame is exact.\n */\n\n/** Full playback loop, in seconds of real time. */\nconst LOOP_SECONDS = 48;\nconst TRACK_WIDTH = 572;\nconst PREVIEW_WIDTH = 176;\nconst PREVIEW_HEIGHT = 99;\nconst STRIP = Array.from({ length: 12 }, (_, i) => (i + 0.5) / 12);\nconst SPRING: Transition = { damping: 32, stiffness: 420, type: \"spring\" };\nconst INSTANT: Transition = { duration: 0 };\n\nconst clamp01 = (value: number) => Math.min(1, Math.max(0, value));\nconst lerp = (a: number, b: number, u: number) => a + (b - a) * u;\n\nconst hexToRgb = (hex: string): [number, number, number] => [\n  Number.parseInt(hex.slice(1, 3), 16),\n  Number.parseInt(hex.slice(3, 5), 16),\n  Number.parseInt(hex.slice(5, 7), 16),\n];\n\nconst mixHex = (a: string, b: string, u: number) => {\n  const [ar, ag, ab] = hexToRgb(a);\n  const [br, bg, bb] = hexToRgb(b);\n  return `rgb(${String(Math.round(lerp(ar, br, u)))},${String(Math.round(lerp(ag, bg, u)))},${String(Math.round(lerp(ab, bb, u)))})`;\n};\n\ninterface SkyStop {\n  t: number;\n  top: string;\n  horizon: string;\n  light: number;\n}\n\n// A 24-hour cycle: midnight → dawn → noon → dusk → midnight.\nconst SKY: SkyStop[] = [\n  { horizon: \"#0c1233\", light: 0.1, t: 0, top: \"#04061a\" },\n  { horizon: \"#e2814f\", light: 0.45, t: 0.2, top: \"#1d2a5e\" },\n  { horizon: \"#bcd9f2\", light: 0.95, t: 0.32, top: \"#3f7fd4\" },\n  { horizon: \"#d6ecfa\", light: 1, t: 0.5, top: \"#3b8fe2\" },\n  { horizon: \"#f2b263\", light: 0.85, t: 0.68, top: \"#4a72c4\" },\n  { horizon: \"#d4543a\", light: 0.4, t: 0.8, top: \"#251d4f\" },\n  { horizon: \"#0c1233\", light: 0.1, t: 1, top: \"#04061a\" },\n];\n\nconst skyAt = (t: number) => {\n  const clamped = clamp01(t);\n  let upper = SKY.length - 1;\n  for (let i = 1; i < SKY.length; i += 1) {\n    if ((SKY[i]?.t ?? 1) >= clamped) {\n      upper = i;\n      break;\n    }\n  }\n  const a = SKY[upper - 1];\n  const b = SKY[upper];\n  if (!a || !b) {\n    return { horizon: \"#000000\", light: 0, top: \"#000000\" };\n  }\n  const u = b.t === a.t ? 0 : (clamped - a.t) / (b.t - a.t);\n  return {\n    horizon: mixHex(a.horizon, b.horizon, u),\n    light: lerp(a.light, b.light, u),\n    top: mixHex(a.top, b.top, u),\n  };\n};\n\n/** Sun (day) or moon (night) position along an arc. */\nconst orbAt = (t: number) => {\n  const day = t >= 0.25 && t <= 0.79;\n  // Map the day window (or the wrapped night window) onto 0..1.\n  const u = day ? (t - 0.25) / 0.54 : ((t + 1 - 0.79) % 1) / 0.46;\n  return {\n    day,\n    x: lerp(8, 92, u),\n    y: 78 - Math.sin(u * Math.PI) * 58,\n  };\n};\n\nconst timeLabel = (t: number) => {\n  const minutes = Math.round(clamp01(t) * 24 * 60) % (24 * 60);\n  const h = Math.floor(minutes / 60);\n  const m = minutes % 60;\n  return `${h.toString().padStart(2, \"0\")}:${m.toString().padStart(2, \"0\")}`;\n};\n\n/** One rendered \"video frame\" at time t — pure CSS, no assets. */\nconst Frame: FC<{ t: number }> = ({ t }) => {\n  const sky = skyAt(t);\n  const orb = orbAt(t);\n  return (\n    <div\n      className=\"relative size-full overflow-hidden\"\n      style={{\n        background: `linear-gradient(to bottom, ${sky.top} 0%, ${sky.horizon} 78%, ${sky.horizon} 100%)`,\n      }}\n    >\n      <div\n        className=\"absolute rounded-full\"\n        style={{\n          aspectRatio: \"1\",\n          background: orb.day\n            ? \"radial-gradient(circle, #fff7db 30%, #ffd76a 60%, transparent 72%)\"\n            : \"radial-gradient(circle, #e8edf5 38%, #b9c4d8 55%, transparent 66%)\",\n          left: `${String(orb.x)}%`,\n          opacity: orb.day ? 1 : 0.9,\n          top: `${String(orb.y)}%`,\n          transform: \"translate(-50%, -50%)\",\n          width: \"14%\",\n        }}\n      />\n      <div\n        className=\"absolute inset-x-0 bottom-0 h-[46%]\"\n        style={{\n          background: mixHex(\"#0a0f1e\", \"#31543c\", sky.light),\n          clipPath:\n            \"polygon(0 58%, 16% 30%, 34% 52%, 52% 18%, 70% 46%, 86% 28%, 100% 50%, 100% 100%, 0 100%)\",\n        }}\n      />\n      <div\n        className=\"absolute inset-x-0 bottom-0 h-[30%]\"\n        style={{\n          background: mixHex(\"#060a14\", \"#1d3527\", sky.light),\n          clipPath:\n            \"polygon(0 40%, 22% 68%, 40% 34%, 62% 66%, 80% 40%, 100% 62%, 100% 100%, 0 100%)\",\n        }}\n      />\n    </div>\n  );\n};\n\nexport const SeekFilmstrip = () => {\n  const [playedT, setPlayedT] = useState(0.3);\n  // null until the viewer chooses; reduced motion means the timelapse doesn't autoplay.\n  const [playChoice, setPlayChoice] = useState<boolean | null>(null);\n  const reduced = useReducedMotion() ?? false;\n  const playing = playChoice ?? !reduced;\n  const [scrubT, setScrubT] = useState<number | null>(null);\n  const scrubbing = scrubT !== null;\n  const trackRef = useRef<HTMLDivElement | null>(null);\n  const wasPlayingRef = useRef(false);\n\n  useEffect(() => {\n    if (!playing || scrubbing) {\n      return;\n    }\n    let raf = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      const dt = (now - last) / 1000;\n      last = now;\n      setPlayedT((current) => (current + dt / LOOP_SECONDS) % 1);\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [playing, scrubbing]);\n\n  const tFromPointer = (clientX: number) => {\n    const rect = trackRef.current?.getBoundingClientRect();\n    if (!rect) {\n      return 0;\n    }\n    return clamp01((clientX - rect.left) / rect.width);\n  };\n\n  const endScrub = (commitT: number | null) => {\n    if (commitT !== null) {\n      setPlayedT(commitT);\n    }\n    setScrubT(null);\n    setPlayChoice(wasPlayingRef.current);\n  };\n\n  const headT = scrubT ?? playedT;\n  const previewLeft = Math.min(\n    TRACK_WIDTH - PREVIEW_WIDTH,\n    Math.max(0, headT * TRACK_WIDTH - PREVIEW_WIDTH / 2),\n  );\n  const spring = reduced ? INSTANT : SPRING;\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className=\"w-[620px] overflow-hidden rounded-3xl bg-[#0a1122] shadow-2xl shadow-black/60 ring-1 ring-white/10 select-none\">\n        <div className=\"relative h-[300px]\">\n          <Frame t={playedT} />\n          <motion.div\n            aria-hidden\n            animate={{ opacity: scrubbing ? 0.3 : 0 }}\n            className=\"pointer-events-none absolute inset-0 bg-black\"\n          />\n          <div className=\"absolute top-4 left-5 text-[12px] font-medium text-white/85 drop-shadow\">\n            One Day — timelapse\n          </div>\n        </div>\n\n        <div className=\"px-6 pt-4 pb-5\">\n          <div className=\"relative\" style={{ width: TRACK_WIDTH }}>\n            <AnimatePresence>\n              {scrubT !== null && (\n                <motion.div\n                  initial={{ opacity: 0, scale: 0.9, y: 8 }}\n                  animate={{ opacity: 1, scale: 1, y: 0 }}\n                  exit={{ opacity: 0, scale: 0.94, y: 6 }}\n                  transition={spring}\n                  className=\"pointer-events-none absolute bottom-[calc(100%+10px)] z-10 origin-bottom overflow-hidden rounded-lg shadow-2xl ring-2 shadow-black/70 ring-white\"\n                  style={{ height: PREVIEW_HEIGHT, left: previewLeft, width: PREVIEW_WIDTH }}\n                >\n                  <Frame t={scrubT} />\n                  <span className=\"absolute bottom-1.5 left-1/2 -translate-x-1/2 rounded bg-black/60 px-1.5 py-px text-[11px] font-medium text-white tabular-nums\">\n                    {timeLabel(scrubT)}\n                  </span>\n                </motion.div>\n              )}\n            </AnimatePresence>\n\n            <div\n              ref={trackRef}\n              className=\"relative flex h-[44px] cursor-pointer touch-none items-center\"\n              onPointerDown={(event) => {\n                event.currentTarget.setPointerCapture(event.pointerId);\n                wasPlayingRef.current = playing;\n                setPlayChoice(false);\n                setScrubT(tFromPointer(event.clientX));\n              }}\n              onPointerMove={(event) => {\n                if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n                  setScrubT(tFromPointer(event.clientX));\n                }\n              }}\n              onPointerUp={(event) => endScrub(tFromPointer(event.clientX))}\n              onPointerCancel={() => endScrub(null)}\n            >\n              <motion.div\n                animate={{ height: scrubbing ? 40 : 5 }}\n                initial={false}\n                transition={spring}\n                className=\"relative flex w-full gap-[2px] overflow-hidden rounded-md bg-white/12\"\n              >\n                {STRIP.map((t) => (\n                  <motion.div\n                    key={t}\n                    animate={{ opacity: scrubbing ? 1 : 0 }}\n                    initial={false}\n                    className=\"h-full flex-1\"\n                  >\n                    <Frame t={t} />\n                  </motion.div>\n                ))}\n                <div\n                  className=\"absolute inset-y-0 left-0 bg-white/85\"\n                  style={{ opacity: scrubbing ? 0 : 1, width: `${String(playedT * 100)}%` }}\n                />\n                <div\n                  className=\"absolute inset-y-0 right-0 bg-black/45\"\n                  style={{\n                    opacity: scrubbing ? 1 : 0,\n                    width: `${String((1 - headT) * 100)}%`,\n                  }}\n                />\n              </motion.div>\n              <motion.div\n                animate={{ height: scrubbing ? 52 : 13, width: scrubbing ? 3 : 13 }}\n                initial={false}\n                transition={spring}\n                className=\"pointer-events-none absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow-md shadow-black/60\"\n                style={{ left: `${String(headT * 100)}%` }}\n              />\n            </div>\n          </div>\n\n          <div className=\"mt-2 flex items-center justify-between\">\n            <span className=\"text-[12px] font-medium text-white/70 tabular-nums\">\n              {timeLabel(playedT)}\n            </span>\n            <button\n              type=\"button\"\n              onClick={() => setPlayChoice(!playing)}\n              className=\"rounded-full bg-white/10 px-4 py-1.5 text-[12px] font-medium text-white transition-colors hover:bg-white/15\"\n            >\n              {playing ? \"Pause\" : \"Play\"}\n            </button>\n            <span className=\"text-[12px] text-white/35 tabular-nums\">24:00</span>\n          </div>\n        </div>\n      </div>\n    </MotionConfig>\n  );\n};\n","path":"/seek-filmstrip.tsx","target":"uicapsule/seek-filmstrip/seek-filmstrip.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/seek-filmstrip","name":"seek-filmstrip","registryDependencies":[],"title":"Seek Filmstrip","type":"registry:block"}