{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-sequence-player",
  "title": "useSequencePlayer",
  "description": "A client-safe React hook that spotlights one scripted animation sequence at a time, pausing off-screen, on hidden tabs, while focused, and under reduced motion.",
  "registryDependencies": [
    "https://ui.ericts.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/base/hooks/use-sequence-player.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\n/**\n * One scripted sequence: how many beats it plays and how long each beat rests.\n *\n * `stepMs` accepts an array when the beats are unequal — a code-entry demo that\n * types four digits and then holds on a success check needs a long final beat,\n * and averaging that into one number makes the payoff read as a glitch. The last\n * entry repeats if the array is shorter than `steps`.\n */\nexport type SequenceScript = {\n  steps: number;\n  stepMs: number | readonly number[];\n};\n\nexport type UseSequencePlayerOptions = {\n  /** One entry per sequence, in spotlight order. */\n  sequences: readonly SequenceScript[];\n  /**\n   * Quiet beat before the first sequence starts, so a page that just scrolled\n   * into view settles before anything moves. Defaults to 500ms.\n   */\n  leadInMs?: number;\n  /** Set false to hold everything at its poster frame. Defaults to true. */\n  enabled?: boolean;\n  /**\n   * Whether `takeOver` keeps replaying its sequence instead of handing off.\n   * Defaults to true — the point of taking over is to watch one thing repeat.\n   */\n  loopTakeOver?: boolean;\n};\n\ntype ContainerProps = {\n  ref: (node: HTMLElement | null) => void;\n  onFocusCapture: () => void;\n  onBlurCapture: (event: { currentTarget: HTMLElement; relatedTarget: EventTarget | null }) => void;\n  onPointerLeave: () => void;\n};\n\nexport type UseSequencePlayerResult = {\n  /**\n   * Spread onto the element that wraps every sequence. Supplies the visibility\n   * observer plus the focus and pointer pauses; without it the player would keep\n   * animating off-screen and would yank the spotlight away from a keyboard user\n   * reading one sequence.\n   */\n  containerProps: ContainerProps;\n  /** Index of the sequence currently holding the spotlight. */\n  activeIndex: number;\n  /** Beats already fired in the active sequence; the last one is still resting. */\n  stepsFired: number;\n  /**\n   * Per-sequence play counter. Feed `runs[i]` to a sequence as a prop (or a\n   * React `key`) and derive its visual state from that number — the player never\n   * needs to know what any sequence actually renders.\n   */\n  runs: readonly number[];\n  /** False while paused: reduced motion, off-screen, background tab, or focused. */\n  isPlaying: boolean;\n  /** Dwell for one beat, for driving a progress indicator. */\n  dwellMs: (sequenceIndex: number, step: number) => number;\n  /** Move the spotlight to a sequence now — hover, click, or focus. */\n  takeOver: (index: number) => void;\n  /** Hand the spotlight back to automatic advancing. */\n  release: () => void;\n};\n\ntype Playback = {\n  index: number;\n  /** Beats already fired in this cycle; the last one is still resting. */\n  stepsFired: number;\n  /** \"taken\" holds the spotlight on one sequence; \"auto\" advances through all. */\n  mode: \"auto\" | \"taken\";\n};\n\nfunction resolveDwell(stepMs: SequenceScript[\"stepMs\"], step: number) {\n  return typeof stepMs === \"number\"\n    ? stepMs\n    : (stepMs[Math.min(step, stepMs.length - 1)] ?? 0);\n}\n\n/**\n * Drives a set of scripted sequences so exactly one plays at a time, then hands\n * the spotlight to the next.\n *\n * Why this exists: showing several animated demos at once is noise — every one\n * competes for the same attention and none of them reads. The fix is a spotlight,\n * and the fiddly part is not the timer but knowing when *not* to run it. This\n * hook holds all of that: it stays still until the container is on screen, pauses\n * on a hidden tab, pauses while focus is inside (so a keyboard user is never\n * interrupted mid-read), honours `prefers-reduced-motion` by never starting, and\n * lets a pointer take the spotlight and give it back.\n *\n * The hook is deliberately content-blind. It never renders anything and never\n * learns what a sequence is; it only counts beats and tells you which sequence\n * is on beat `n`. Sequences derive their own state from `runs[i]`.\n *\n * @example\n *   const SCRIPTS = [\n *     { steps: 3, stepMs: 1500 },\n *     { steps: 5, stepMs: [600, 600, 600, 1900, 500] },\n *   ];\n *\n *   const { containerProps, activeIndex, runs, takeOver } = useSequencePlayer({\n *     sequences: SCRIPTS,\n *   });\n *\n *   return (\n *     <div {...containerProps}>\n *       {SCRIPTS.map((_, index) => (\n *         <div key={index} onPointerEnter={() => takeOver(index)}>\n *           <Demo run={runs[index] ?? 0} playing={index === activeIndex} />\n *         </div>\n *       ))}\n *     </div>\n *   );\n *\n * Notes for animators:\n * - Beat 0 is a *poster frame*: nothing has fired yet, so every sequence should\n *   look deliberate at `run === 0`. A sequence whose resting state is empty reads\n *   as a broken box for as long as it waits its turn.\n * - `stepsFired` counts beats already started, so the beat currently resting is\n *   `stepsFired - 1`. That is the one a progress indicator should be filling.\n * - The container ref is a *callback ref*, so the observer reattaches correctly\n *   when the wrapper is conditionally rendered.\n * - Under reduced motion the player never starts and `runs` stays all zeroes,\n *   which leaves every sequence on its poster frame rather than mid-animation.\n */\nexport function useSequencePlayer({\n  sequences,\n  leadInMs = 500,\n  enabled = true,\n  loopTakeOver = true,\n}: UseSequencePlayerOptions): UseSequencePlayerResult {\n  const count = sequences.length;\n  const prefersReducedMotion = useReducedMotion();\n  const [runs, setRuns] = useState<number[]>([]);\n  const [playback, setPlayback] = useState<Playback>({\n    index: 0,\n    stepsFired: 0,\n    mode: \"auto\",\n  });\n  const [inView, setInView] = useState(false);\n  const [pageVisible, setPageVisible] = useState(true);\n  const [focusPaused, setFocusPaused] = useState(false);\n  const observerRef = useRef<IntersectionObserver | null>(null);\n\n  const containerRef = useCallback((node: HTMLElement | null) => {\n    observerRef.current?.disconnect();\n    observerRef.current = null;\n\n    if (!node) return;\n\n    // Without an observer we cannot know when to start, so assume visible\n    // rather than silently never playing.\n    if (typeof IntersectionObserver === \"undefined\") {\n      setInView(true);\n      return;\n    }\n\n    const observer = new IntersectionObserver(\n      ([entry]) => setInView(entry?.isIntersecting ?? false),\n      { threshold: 0.2 },\n    );\n\n    observer.observe(node);\n    observerRef.current = observer;\n  }, []);\n\n  useEffect(() => () => observerRef.current?.disconnect(), []);\n\n  useEffect(() => {\n    if (typeof document === \"undefined\") return;\n\n    const update = () => setPageVisible(!document.hidden);\n\n    update();\n    document.addEventListener(\"visibilitychange\", update);\n\n    return () => document.removeEventListener(\"visibilitychange\", update);\n  }, []);\n\n  const fire = useCallback(\n    (index: number) => {\n      setRuns((current) =>\n        Array.from(\n          { length: count },\n          (_, i) => (current[i] ?? 0) + (i === index ? 1 : 0),\n        ),\n      );\n    },\n    [count],\n  );\n\n  const isPlaying =\n    enabled &&\n    !prefersReducedMotion &&\n    inView &&\n    pageVisible &&\n    !focusPaused &&\n    count > 0;\n\n  useEffect(() => {\n    if (!isPlaying) return;\n\n    const index = playback.index % count;\n    const script = sequences[index];\n\n    if (!script) return;\n\n    const delay =\n      playback.stepsFired === 0\n        ? leadInMs\n        : resolveDwell(script.stepMs, playback.stepsFired - 1);\n\n    const timeout = setTimeout(() => {\n      if (playback.stepsFired < script.steps) {\n        fire(index);\n        setPlayback((current) => ({\n          ...current,\n          stepsFired: current.stepsFired + 1,\n        }));\n        return;\n      }\n\n      if (playback.mode === \"taken\" && loopTakeOver) {\n        fire(index);\n        setPlayback((current) => ({ ...current, stepsFired: 1 }));\n        return;\n      }\n\n      const next = (index + 1) % count;\n\n      fire(next);\n      setPlayback({ index: next, stepsFired: 1, mode: \"auto\" });\n    }, delay);\n\n    return () => clearTimeout(timeout);\n  }, [count, fire, isPlaying, leadInMs, loopTakeOver, playback, sequences]);\n\n  const takeOver = useCallback(\n    (index: number) => {\n      if (index < 0 || index >= count) return;\n\n      fire(index);\n      setPlayback({ index, stepsFired: 1, mode: \"taken\" });\n    },\n    [count, fire],\n  );\n\n  const release = useCallback(() => {\n    setPlayback((current) =>\n      current.mode === \"taken\" ? { ...current, mode: \"auto\" } : current,\n    );\n  }, []);\n\n  const dwellMs = useCallback(\n    (sequenceIndex: number, step: number) => {\n      const script = sequences[sequenceIndex];\n\n      return script ? resolveDwell(script.stepMs, step) : 0;\n    },\n    [sequences],\n  );\n\n  return {\n    containerProps: {\n      ref: containerRef,\n      onFocusCapture: () => setFocusPaused(true),\n      onBlurCapture: (event) => {\n        // Focus moving between sequences inside the container is not a release;\n        // only focus actually leaving resumes the player.\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n          setFocusPaused(false);\n        }\n      },\n      onPointerLeave: release,\n    },\n    activeIndex: count > 0 ? playback.index % count : 0,\n    stepsFired: playback.stepsFired,\n    runs,\n    isPlaying,\n    dwellMs,\n    takeOver,\n    release,\n  };\n}\n",
      "type": "registry:hook",
      "target": "@hooks/use-sequence-player.ts"
    }
  ],
  "meta": {
    "tags": [
      "spotlight",
      "playback",
      "intersection-observer",
      "reduced-motion",
      "showcase"
    ],
    "effects": [
      "sequenced-playback"
    ]
  },
  "categories": [
    "motion"
  ],
  "type": "registry:hook"
}