{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "media-scrubber",
  "type": "registry:ui",
  "title": "Media Scrubber",
  "description": "Seek bar with chapters, buffered ranges and a time tip that tracks the pointer.",
  "categories": [
    "sliders"
  ],
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/media-scrubber.tsx",
      "content": "\"use client\";\nimport { Slider } from \"@base-ui/react/slider\";\nimport { useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type MediaChapter = { start: number; title: string };\n\n/** 83 → \"1:23\", 3725 → \"1:02:05\". Locale-free, so server and client agree. */\nexport function formatTime(seconds: number, pad = false) {\n  const s = Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0));\n  const h = Math.floor(s / 3600);\n  const m = Math.floor((s % 3600) / 60);\n  const sec = String(s % 60).padStart(2, \"0\");\n  return h > 0 || pad ? `${h}:${String(m).padStart(2, \"0\")}:${sec}` : `${m}:${sec}`;\n}\n\nfunction spokenTime(seconds: number) {\n  const s = Math.max(0, Math.floor(seconds));\n  const parts = [\n    [Math.floor(s / 3600), \"hour\"],\n    [Math.floor((s % 3600) / 60), \"minute\"],\n    [s % 60, \"second\"],\n  ] as const;\n  const said = parts.filter(([n], i) => n > 0 || (i === 2 && s === 0)).map(([n, unit]) => `${n} ${unit}${n === 1 ? \"\" : \"s\"}`);\n  return said.join(\" \");\n}\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\nexport type MediaScrubberProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"children\"> & {\n  /** Current time in seconds, usually mirrored from the media element's timeupdate. */\n  value?: number;\n  defaultValue?: number;\n  /** Fires while scrubbing and on every key press. Use it to preview; seek on commit. */\n  onValueChange?: (time: number) => void;\n  /** Fires when a drag is released, the track is clicked or a key moves the time. This is where you seek. */\n  onValueCommitted?: (time: number) => void;\n  /** Total length in seconds. 0, NaN or Infinity (metadata not loaded yet) disables the scrubber. */\n  duration: number;\n  /** Buffered end in seconds, or the ranges from media.buffered as [start, end] pairs. */\n  buffered?: number | Array<[number, number]>;\n  /** Sorted or not; each chapter runs until the next one starts. */\n  chapters?: MediaChapter[];\n  /** Seconds moved by the arrow keys. */\n  keyStep?: number;\n  /** Seconds moved by Shift+Arrow and Page Up/Down. */\n  largeKeyStep?: number;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n};\n\nexport function MediaScrubber({\n  value: valueProp,\n  defaultValue = 0,\n  onValueChange,\n  onValueCommitted,\n  duration,\n  buffered,\n  chapters,\n  keyStep = 5,\n  largeKeyStep = 10,\n  disabled: disabledProp = false,\n  className,\n  \"aria-label\": ariaLabel = \"Seek\",\n  ...rest\n}: MediaScrubberProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const ready = Number.isFinite(duration) && duration > 0;\n  const disabled = disabledProp || !ready;\n  const max = ready ? duration : 1;\n\n  // While a drag is in progress the thumb shows the scrub position, not the\n  // playing time the parent keeps pushing in, so the two never fight.\n  const [scrub, setScrub] = useState<number | null>(null);\n  const [hover, setHover] = useState<{ time: number } | null>(null);\n  const [keyboard, setKeyboard] = useState(false);\n  const trackRef = useRef<HTMLDivElement>(null);\n  const tipRef = useRef<HTMLDivElement>(null);\n\n  const time = clamp(scrub ?? value, 0, max);\n  const pct = (t: number) => (clamp(t, 0, max) / max) * 100;\n\n  const sorted = chapters?.length ? [...chapters].sort((a, b) => a.start - b.start) : [{ start: 0, title: \"\" }];\n  const segments = sorted.map((c, i) => ({ ...c, start: i === 0 ? 0 : c.start, end: sorted[i + 1]?.start ?? max }));\n  const chapterAt = (t: number) => segments.findLast((s) => t >= s.start) ?? segments[0];\n  const ranges: Array<[number, number]> = buffered === undefined ? [] : typeof buffered === \"number\" ? [[0, buffered]] : buffered;\n\n  const tipTime = scrub ?? (keyboard ? time : hover?.time ?? null);\n  const tipOpen = !disabled && tipTime !== null;\n  const tipChapter = tipTime !== null && chapters?.length ? chapterAt(tipTime).title : null;\n  const hoveredChapter = hover && chapters?.length ? chapterAt(hover.time) : null;\n\n  // The tip follows the pointer 1:1 but stays inside the track's edges.\n  const placeTip = (t: number) => {\n    const track = trackRef.current;\n    if (!track) return 0;\n    const w = track.clientWidth;\n    const half = (tipRef.current?.offsetWidth ?? 0) / 2;\n    return clamp((pct(t) / 100) * w, Math.min(half, w / 2), Math.max(w - half, w / 2));\n  };\n  const [tipX, setTipX] = useState(0);\n\n  const onPointerMove = (e: React.PointerEvent) => {\n    if (disabled || e.pointerType === \"touch\") return;\n    const rect = trackRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    const t = clamp((e.clientX - rect.left) / rect.width, 0, 1) * max;\n    setHover({ time: t });\n    setKeyboard(false);\n    if (scrub === null) setTipX(placeTip(t));\n  };\n\n  const set = (t: number, commit: boolean) => {\n    const next = clamp(t, 0, max);\n    setValue(next);\n    if (commit) onValueCommitted?.(next);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (disabled) return;\n    const big = e.shiftKey ? largeKeyStep : keyStep;\n    let next: number | null = null;\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") next = time + big;\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") next = time - big;\n    else if (e.key === \"PageUp\") next = time + largeKeyStep;\n    else if (e.key === \"PageDown\") next = time - largeKeyStep;\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = max;\n    // 0–9 jump to that tenth of the way through, as most players do.\n    else if (/^[0-9]$/.test(e.key) && !e.metaKey && !e.ctrlKey && !e.altKey) next = (Number(e.key) / 10) * max;\n    if (next === null) return;\n    // Owning the keys here stops the primitive from snapping to its own step.\n    e.preventDefault();\n    // A thumb focused by the pointer earlier should show its ring once keys take over.\n    const input = e.currentTarget;\n    if (!input.matches(\":focus-visible\")) {\n      input.blur();\n      input.focus({ preventScroll: true, focusVisible: true } as FocusOptions);\n    }\n    setKeyboard(true);\n    set(next, true);\n    setTipX(placeTip(clamp(next, 0, max)));\n  };\n\n  return (\n    <Slider.Root\n      value={time}\n      min={0}\n      max={max}\n      step={0.01}\n      disabled={disabled}\n      onValueChange={(v, details) => {\n        if (details.reason === \"keyboard\") return;\n        const t = v as number;\n        setScrub(t);\n        setKeyboard(false);\n        setTipX(placeTip(t));\n        onValueChange?.(t);\n      }}\n      onValueCommitted={(v, details) => {\n        if (details.reason === \"keyboard\") return;\n        setScrub(null);\n        set(v as number, true);\n      }}\n      data-scrubbing={scrub !== null || undefined}\n      className={cn(\"group/scrub relative w-full touch-none select-none\", disabled && \"opacity-50\", className)}\n      {...rest}\n    >\n      <Slider.Control\n        onPointerMove={onPointerMove}\n        onPointerLeave={() => setHover(null)}\n        className={cn(\"relative flex h-5 w-full items-center [@media(pointer:coarse)]:h-11\", !disabled && \"cursor-pointer\")}\n      >\n        <Slider.Track ref={trackRef} className=\"relative h-1.5 w-full\">\n          {segments.map((s, i) => {\n            const len = Math.max(s.end - s.start, 0.0001);\n            const fill = (t: number) => `${clamp((t - s.start) / len, 0, 1) * 100}%`;\n            const active = hoveredChapter?.start === s.start || (scrub !== null && chapterAt(scrub).start === s.start);\n            return (\n              <div\n                key={s.start}\n                aria-hidden\n                data-active={(active && segments.length > 1) || undefined}\n                // Chapters sit at their true positions with a 2px gap cut from each\n                // side, so the fill and the thumb always line up.\n                style={{\n                  left: `calc(${pct(s.start)}% + ${i === 0 ? 0 : 1}px)`,\n                  width: `calc(${pct(s.end) - pct(s.start)}% - ${(i === 0 ? 0 : 1) + (i === segments.length - 1 ? 0 : 1)}px)`,\n                }}\n                className={cn(\n                  \"absolute inset-y-0 overflow-hidden rounded-full bg-line-2\",\n                  \"scale-y-50 transition-transform duration-150 ease-out-quart motion-reduce:transition-none\",\n                  \"group-hover/scrub:scale-y-[0.7] group-data-[scrubbing]/scrub:scale-y-[0.7] data-[active]:!scale-y-100\",\n                  \"[@media(hover:none)]:scale-y-[0.7]\",\n                )}\n              >\n                {ranges.map(([a, b]) =>\n                  b > s.start && a < s.end ? (\n                    <span\n                      key={`${a}-${b}`}\n                      className=\"absolute inset-y-0 bg-fg-4\"\n                      style={{ left: fill(a), width: `calc(${fill(b)} - ${fill(a)})` }}\n                    />\n                  ) : null,\n                )}\n                {hover && scrub === null && hover.time > time && (\n                  <span className=\"absolute inset-y-0 left-0 bg-fg-3\" style={{ width: fill(hover.time) }} />\n                )}\n                <span className=\"absolute inset-y-0 left-0 bg-fg\" style={{ width: fill(time) }} />\n              </div>\n            );\n          })}\n\n          <Slider.Thumb\n            aria-label={ariaLabel}\n            getAriaValueText={(_, v) => {\n              const c = chapters?.length ? chapterAt(v).title : \"\";\n              return `${spokenTime(v)} of ${spokenTime(max)}${c ? `, ${c}` : \"\"}`;\n            }}\n            onKeyDown={onKeyDown}\n            onFocus={(e) => setKeyboard(e.currentTarget.matches(\":focus-visible\"))}\n            onBlur={() => setKeyboard(false)}\n            className={cn(\n              \"size-3 rounded-full bg-fg shadow-[var(--shadow)]\",\n              // Hidden at rest so the bar reads as a clean line; it arrives on hover,\n              // focus or touch, and swells a little while held.\n              \"scale-0 transition-[scale] duration-150 ease-out-quart motion-reduce:transition-none\",\n              \"group-hover/scrub:scale-100 has-[:focus-visible]:scale-100 [@media(hover:none)]:scale-100\",\n              \"group-data-[scrubbing]/scrub:scale-[1.25] motion-reduce:group-data-[scrubbing]/scrub:scale-100\",\n              \"has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-fg-3\",\n              disabled && \"hidden\",\n            )}\n          />\n        </Slider.Track>\n      </Slider.Control>\n\n      <div\n        ref={tipRef}\n        aria-hidden\n        style={{ transform: `translateX(calc(${tipX}px - 50%))` }}\n        className=\"pointer-events-none absolute bottom-full left-0 mb-1.5 z-(--z-tooltip)\"\n      >\n        <div\n          data-open={tipOpen || undefined}\n          className={cn(\n            \"flex origin-bottom flex-col items-center rounded-md border border-line-2 bg-raised px-2 py-1 shadow-pop\",\n            \"translate-y-0.5 scale-[0.96] opacity-0 transition-[opacity,scale,translate] duration-100 ease-out\",\n            \"data-[open]:translate-y-0 data-[open]:scale-100 data-[open]:opacity-100 data-[open]:duration-150 data-[open]:ease-out-expo\",\n            \"motion-reduce:translate-y-0 motion-reduce:scale-100\",\n          )}\n        >\n          {tipChapter && <span className=\"max-w-[180px] truncate text-[11px] leading-[14px] text-fg-2\">{tipChapter}</span>}\n          <span className=\"tabular text-[12px] font-medium leading-4 text-fg\">{formatTime(tipTime ?? 0, max >= 3600)}</span>\n        </div>\n      </div>\n    </Slider.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/media-scrubber"
}
