{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "media-scrubber",
  "title": "Media Scrubber",
  "description": "The playback position bar for an <audio> or <video> element you already have — the filled progress, the buffered-ahead shading, a draggable thumb, keyboard seeking, and a screen-reader announcement that says \"1 minute 12 seconds of 5 minutes 0 seconds\" instead of a bare number. It attaches to your own media element by ref rather than rendering one, so it stays a position bar and leaves the transport buttons, layout and sources to you. Reach for it wherever media has a timeline: a custom audio player, a podcast or audiobook player, a voice-note or voice-memo field, a video player or preview, a lecture, webinar or course-recording player, a meeting or call recording, a screen-recording review, a security-camera or dashcam playback view, a music or sample preview, a transcript synced to audio, a video trimming or clip-selection UI, and the playback half of a recorder (it pairs with audio-recorder in this registry, which records but hands playback to the browser's own control). It is built around the four things that make a seek bar quietly wrong, each measured rather than assumed. (1) duration is NaN before metadata loads and Infinity for a live stream — and for every clip MediaRecorder produces — so pointerX / width * duration is NaN exactly on first paint; assigning a non-finite currentTime throws TypeError rather than seeking, which unwinds the handler and leaves a drag stuck to the pointer. Every path here is guarded for finiteness, not for range, because the element already clamps finite out-of-range values itself. A duration prop covers the MediaRecorder case where the file never learns its own length. (2) buffered is a TimeRanges, which is not iterable, has no .map, and has no index properties: [...buffered] throws, and Array.from(buffered) succeeds and lies — it returns an array of the right length full of undefined. It is read by index here, and the buffered bar stops at the range containing the playhead rather than the last range, so a buffer with a hole in front of the playhead is not drawn as a clip that will play through. (3) timeupdate fires at most four times a second, so the bar reads currentTime on an animation frame instead — re-reading the element, never accumulating its own clock — and commits only when the playhead would move to a different position on the track. (4) the thumb follows the pointer during a drag rather than currentTime, because seeking is asynchronous and the element keeps reporting the old position until it completes, so a bar rendered from currentTime crawls out from under the finger. Accessibility is the part a hand-rolled bar usually skips: role=slider with aria-valuemin/max/now and an aria-valuetext in words, all three value attributes withheld together while the length is unknown (publishing valuenow alone states 0% of a clip nobody has measured, because ARIA's implied maximum is 100), aria-disabled until a seek is actually possible, Arrow and Page keys, Home and End, a focus-visible ring on the thumb, horizontal arrows mirrored under RTL while the vertical ones are not, touch-none so a drag on a phone is a seek and not a page scroll, pointer capture with pointer-id checks so a second finger cannot move or end someone else's drag, and the release's own coordinate committed rather than the last coalesced pointermove. Common asks it answers: \"audio player progress bar react\", \"video seek bar react\", \"custom audio player scrubber react\", \"react media player progress bar\", \"shadcn audio player\", \"shadcn video player progress bar\", \"shadcn seek bar\", \"shadcn media scrubber\", \"html5 audio currentTime react\", \"react audio seek slider\", \"accessible audio seek bar\", \"aria slider for audio position\", \"screen reader audio player position\", \"audio buffered progress bar react\", \"show buffered range audio react\", \"video buffered bar react\", \"buffered.map is not a function\", \"buffered is not iterable\", \"TimeRanges not iterable\", \"Array.from buffered empty\", \"how to read TimeRanges\", \"audio duration NaN\", \"video duration NaN react\", \"duration is Infinity audio\", \"MediaRecorder blob duration Infinity\", \"recorded audio duration Infinity\", \"Failed to set currentTime non-finite\", \"currentTime TypeError non-finite\", \"setting currentTime throws\", \"seek bar thumb jumps while dragging\", \"progress bar jumps back after seek\", \"scrubber lags behind cursor\", \"choppy audio progress bar\", \"timeupdate only fires 4 times a second\", \"smooth progress bar requestAnimationFrame audio\", \"progress bar stutters during playback\", \"seek bar does not work on mobile\", \"drag seek bar scrolls the page instead\", \"live stream duration Infinity seek bar\", \"podcast player seek bar react\", \"voice note playback react\", \"keyboard accessible video scrubber\", \"seek with arrow keys react\", \"next.js audio player progress bar\", \"tailwind audio seek bar\", \"typescript audio scrubber component\", \"音声 シークバー react\", \"動画 再生位置 バー\", \"オーディオプレイヤー 進捗バー\", \"シークバー 自作 react\", \"再生位置 アクセシビリティ\", \"ボイスメモ 再生 react\". Different from official shadcn/ui, which ships nothing for media at all — measured across all 62 of its components and 223,287 bytes of source, currentTime, buffered, seeking, TimeRanges, <audio>, <video>, scrubber, seekable and playbackRate are all zero hits, and so is role=\"slider\". Its slider is a Radix value slider with no notion of time, buffered ranges, an unknown or unbounded duration, or a position announced as a clock reading; its progress is a display-only bar you cannot seek with. Distinct from the neighbours in this registry: `audio-recorder` is the capture half and hands playback to the browser, `duration-input` types a length rather than seeking one, `progress-ring` and `ratio-bar` display a proportion you cannot drag, `countdown` counts time down rather than pointing at a position in it, and `scroll-progress` tracks the page rather than a media element.",
  "files": [
    {
      "path": "registry/ui/media-scrubber.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Seconds moved by one arrow key press.\n *\n * Five is the step every player with a keyboard path has converged on, and it is not arbitrary: a\n * one-second step makes a keyboard user press an arrow ninety times to cross a short podcast, and a\n * ten-second step cannot land on a word.\n */\nexport const DEFAULT_SEEK_STEP = 5\n\n/** Seconds moved by Page Up / Page Down. */\nexport const DEFAULT_SEEK_STEP_LARGE = 30\n\n/**\n * How many positions across the track the playhead is allowed to stop at while playing.\n *\n * This is the render budget, and it exists because of where the position comes from. The element\n * announces progress through `timeupdate`, which the spec pins at no more often than every 250ms,\n * so a bar driven by that event alone advances four times a second and visibly stutters. The fix is\n * to read `currentTime` on every animation frame instead — but committing that to React state sixty\n * times a second re-renders the component for motion finer than a pixel.\n *\n * So the frame loop reads continuously and commits only when the playhead would land on a different\n * one of these positions. 1000 is finer than the pixel width of any track this is drawn at, so the\n * motion is smooth at the cost of at most a thousand renders across the whole clip, whatever its\n * length. Note that it bounds renders *per clip*, not per second: a three-second clip and a\n * three-hour one each cost a thousand.\n */\nexport const DEFAULT_TIME_STEPS = 1000\n\n/** The shape of one entry in a `TimeRanges`, as a plain object. */\nexport interface TimeRange {\n  readonly start: number\n  readonly end: number\n}\n\n/**\n * Whether a duration can be used as the denominator of a position.\n *\n * Every arithmetic path in this file is guarded by this rather than by a range check, because the\n * two values a media element actually reports when it does not know the length are the two that\n * survive range checks and then poison everything downstream:\n *\n *   - `NaN` before metadata has loaded, which is what `duration` reads on first paint — measured on\n *     Chromium at `readyState` 0. `NaN` compares false against every bound, so `if (d > 0)` and\n *     `if (d < max)` both decline to catch it, and `time / NaN` is `NaN`.\n *   - `Infinity` for a live stream, and for any file whose header was written before its length was\n *     known. That is not an edge case here: a clip from `MediaRecorder` reports `Infinity` for its\n *     whole life (see `audio-recorder` in this registry, which prints the duration it measured\n *     while recording precisely because the file cannot answer). `time / Infinity` is 0, so a\n *     position bar silently pins to the left edge instead of failing.\n *\n * What makes this worth a guard of its own rather than a clamp is the measured behaviour of the\n * setter. Assigning a finite out-of-range time is *clamped* by the element — on a 3s clip,\n * `currentTime = 999` reads back as 3 and `currentTime = -5` reads back as 0 — but assigning `NaN`\n * or `Infinity` **throws `TypeError`** (\"the provided double value is non-finite\"). So the element\n * forgives the mistake a range check catches and refuses the one it misses, and a handler that\n * computes `pointerX / width * duration` before metadata arrives throws out of the event rather\n * than seeking to the wrong place. See {@link seekMedia}.\n */\nexport function isSeekableDuration(duration: number | null | undefined): duration is number {\n  return typeof duration === \"number\" && Number.isFinite(duration) && duration > 0\n}\n\n/**\n * Whether a media element reports an unbounded length — live, or a file with no length in its\n * header.\n *\n * Kept apart from \"not seekable yet\" because the two want opposite treatment in the UI: metadata\n * that has not arrived is a position that is about to exist, and an unbounded duration is a\n * position that never will.\n */\nexport function isLiveDuration(duration: number | null | undefined): boolean {\n  return duration === Number.POSITIVE_INFINITY\n}\n\n/**\n * `time` brought inside `[0, duration]`.\n *\n * Guarantees a finite result for any input, which is what the pointer and keyboard paths rely on:\n * everything they compute passes through here before it reaches the element, and the element throws\n * on a non-finite assignment (see {@link isSeekableDuration}). `-Infinity` lands on 0 and\n * `+Infinity` on the duration, as a clamp should; `NaN` has no position on the line at all, so it\n * answers 0 — the one arbitrary choice here, and the safe direction, since a seek to the start is\n * recoverable and a throw out of an event handler is not.\n *\n * An unusable duration leaves the upper bound unknown rather than zero, so a finite time passes\n * through: clamping to 0 here would send a playing clip back to the start every time the duration\n * was momentarily unreadable.\n */\nexport function clampTime(time: number, duration: number): number {\n  if (Number.isNaN(time)) return 0\n  if (time <= 0) return 0\n  if (!isSeekableDuration(duration)) return Number.isFinite(time) ? time : 0\n  return time > duration ? duration : time\n}\n\n/**\n * A `TimeRanges` copied into a plain array.\n *\n * The copy is the point. `TimeRanges` is not a collection in any sense JavaScript recognises —\n * measured on Chromium, `buffered` is `[object TimeRanges]` with `Array.isArray` false, `.map`\n * `undefined`, **no `Symbol.iterator`**, and no index properties (`buffered[0]` is `undefined`).\n * So all three obvious ways to read it are wrong, and the quietest one is the one most likely to be\n * written:\n *\n *   - `[...buffered]` throws `TypeError: buffered is not iterable`, which at least says so.\n *   - `buffered.map(...)` throws too.\n *   - `Array.from(buffered)` **succeeds and lies**. It reads the `length` and finds no indices, so\n *     a buffer holding one range comes back as `[undefined]` — an array of the right length, full\n *     of nothing. A `.length` check on it passes, so the code believes it has the ranges, and the\n *     failure surfaces later and elsewhere as \"cannot read properties of undefined (reading 'end')\".\n *\n * The only working access is `length` with `start(i)` / `end(i)`, which is what this does once so\n * that nothing else in the file has to.\n *\n * Ranges carrying a non-finite bound are dropped rather than passed on, so the arithmetic in\n * {@link bufferedEndAt} cannot be handed a `NaN` edge from a source this file does not control.\n */\nexport function readTimeRanges(ranges: TimeRanges | null | undefined): TimeRange[] {\n  if (!ranges) return []\n  const out: TimeRange[] = []\n  // `length` is re-read on each pass on purpose: the buffer is written by the network, so a range\n  // can disappear between the first index and the last, and a cached count would index past the end\n  // and throw `IndexSizeError`.\n  for (let i = 0; i < ranges.length; i++) {\n    const start = ranges.start(i)\n    const end = ranges.end(i)\n    if (!Number.isFinite(start) || !Number.isFinite(end)) continue\n    out.push({ start, end })\n  }\n  return out\n}\n\n/**\n * How far the media is continuously buffered forward from `time`.\n *\n * Returns `time` itself when nothing covers it, so the caller can draw `bufferedEndAt - time` as a\n * width without a special case for \"not buffered\".\n *\n * The reason this is a search rather than `ranges[ranges.length - 1].end` is that `buffered` holds\n * a *set* of ranges, and the set grows holes as soon as anyone skips ahead: play ten seconds, jump\n * to the middle, and the buffer is `[0, 12]` and `[64, 71]` with nothing in between. Reading the\n * last end then reports 71 seconds buffered while the playhead sits at 3 with a gap in front of it,\n * and the bar promises a clip that will in fact stall — the one thing a buffer indicator exists to\n * warn about. Only the range that *contains* the playhead says anything true about what can be\n * played without waiting.\n */\nexport function bufferedEndAt(ranges: readonly TimeRange[], time: number): number {\n  if (!Number.isFinite(time)) return 0\n  for (const range of ranges) {\n    // The playhead sitting exactly on a boundary counts as inside: `end` is exclusive to the\n    // network but a position on it is covered, and treating it as outside makes the buffered bar\n    // flicker to zero once per range as playback crosses the seam.\n    if (time >= range.start && time <= range.end) return range.end\n  }\n  return time\n}\n\n/**\n * Whether two range lists describe the same ranges.\n *\n * Needed because {@link readTimeRanges} necessarily builds a new array every time it is called, and\n * the event that calls it is `progress` — which fires repeatedly all the way through a download,\n * mostly to report that the same ranges now hold a few more bytes. Storing the new array\n * unconditionally re-renders the component several times a second for a bar that has not moved, and\n * a scrubber is on screen for the whole length of the media.\n */\nexport function sameRanges(a: readonly TimeRange[], b: readonly TimeRange[]): boolean {\n  if (a === b) return true\n  if (a.length !== b.length) return false\n  for (let i = 0; i < a.length; i++) {\n    if (a[i].start !== b[i].start || a[i].end !== b[i].end) return false\n  }\n  return true\n}\n\n/**\n * Whether a non-empty `seekable` describes anything a seek could land in.\n *\n * Only meaningful when the element has reported ranges at all: `seekable` is measured as `length` 0\n * both before metadata loads and for a live stream with no DVR window, so emptiness says \"no answer\n * yet\" and \"nothing is seekable\" in the same breath and cannot distinguish them. Callers decide\n * what an empty list means (see `interactive` in {@link useMediaScrubber}); this answers only the\n * case where there is a list to read.\n */\nexport function canSeekRanges(ranges: readonly TimeRange[]): boolean {\n  return ranges.some((range) => range.end > range.start)\n}\n\n/** `time` as a 0–1 position along a track of `duration`, or 0 when there is no usable duration. */\nexport function fractionOfDuration(time: number, duration: number): number {\n  if (!isSeekableDuration(duration) || !Number.isFinite(time)) return 0\n  if (time <= 0) return 0\n  return time >= duration ? 1 : time / duration\n}\n\n/** Whether `node` is laid out right-to-left, which flips both the track and the arrow keys. */\nexport function isRtlElement(node: Element | null | undefined): boolean {\n  if (!node || typeof getComputedStyle !== \"function\") return false\n  return getComputedStyle(node).direction === \"rtl\"\n}\n\nexport interface PointerTimeOptions {\n  /** The pointer's viewport x, straight off the event. */\n  clientX: number\n  /** The track's box, from `getBoundingClientRect()`. */\n  rect: { left: number; width: number }\n  duration: number\n  /** Whether the track runs right-to-left. */\n  rtl?: boolean\n}\n\n/**\n * The time under the pointer.\n *\n * Answers 0 for a zero-width track rather than `NaN` from the division — a track measured before it\n * has been laid out is the ordinary first-render state, not a bug, and `NaN` from here would reach\n * the element's setter and throw (see {@link isSeekableDuration}).\n */\nexport function timeFromPointer({ clientX, rect, duration, rtl = false }: PointerTimeOptions): number {\n  if (!isSeekableDuration(duration) || !(rect.width > 0)) return 0\n  const offset = clientX - rect.left\n  const ratio = rtl ? 1 - offset / rect.width : offset / rect.width\n  return clampTime(ratio * duration, duration)\n}\n\nexport interface KeyboardSeekOptions {\n  key: string\n  currentTime: number\n  duration: number\n  step?: number\n  largeStep?: number\n  /** Whether the track runs right-to-left, which swaps the two horizontal arrows. */\n  rtl?: boolean\n}\n\n/**\n * The time a key press should move to, or `null` when the key is not one this handles.\n *\n * `null` rather than the unchanged time is what lets the caller decide whether to call\n * `preventDefault`: swallowing every key press on a focused slider takes Tab and Escape with it.\n *\n * Up and Down are bound alongside Left and Right because `role=\"slider\"` is a one-dimensional\n * widget whichever way it is drawn, and a user who reaches for Up on a horizontal bar is not making\n * a mistake. They are *not* flipped under RTL — vertical keys have no writing direction, and\n * mirroring them is a bug that only shows up for the users least able to work around it.\n */\nexport function keyboardSeekTarget({\n  key,\n  currentTime,\n  duration,\n  step = DEFAULT_SEEK_STEP,\n  largeStep = DEFAULT_SEEK_STEP_LARGE,\n  rtl = false,\n}: KeyboardSeekOptions): number | null {\n  if (!isSeekableDuration(duration)) return null\n  const base = Number.isFinite(currentTime) ? currentTime : 0\n  const back = rtl ? \"ArrowRight\" : \"ArrowLeft\"\n  const forward = rtl ? \"ArrowLeft\" : \"ArrowRight\"\n\n  switch (key) {\n    case back:\n    case \"ArrowDown\":\n      return clampTime(base - step, duration)\n    case forward:\n    case \"ArrowUp\":\n      return clampTime(base + step, duration)\n    case \"PageDown\":\n      return clampTime(base - largeStep, duration)\n    case \"PageUp\":\n      return clampTime(base + largeStep, duration)\n    case \"Home\":\n      return 0\n    case \"End\":\n      return duration\n    default:\n      return null\n  }\n}\n\n/**\n * Whether a new reading is far enough from the committed one to be worth a render.\n *\n * `steps` is the number of distinct positions the track has; see {@link DEFAULT_TIME_STEPS}. The\n * two ends are always committed even when they round to the same step as the previous reading,\n * because \"finished\" and \"all but finished\" are different states to anything reading the label —\n * and `ended` is the one a player's UI usually branches on.\n */\nexport function shouldCommitTime(\n  previous: number,\n  next: number,\n  duration: number,\n  steps: number = DEFAULT_TIME_STEPS\n): boolean {\n  if (!Number.isFinite(next)) return false\n  if (!Number.isFinite(previous)) return true\n  if (next === previous) return false\n  if (!isSeekableDuration(duration) || !(steps > 0)) return true\n  if (next <= 0 || next >= duration) return true\n  return Math.round((previous / duration) * steps) !== Math.round((next / duration) * steps)\n}\n\n/**\n * Sets `media.currentTime`, and says whether it went through.\n *\n * The finiteness check is the whole function. Assigning a non-finite time throws `TypeError` out of\n * the setter (measured), and the call sites are pointer and key handlers, so the throw does not\n * surface as a failed seek — it unwinds the handler, which means the `pointerup` that would have\n * ended the drag never finishes and the scrubber is left stuck to the pointer. Guarding here rather\n * than at each call site keeps that from being re-litigated per handler.\n *\n * Out-of-range finite times are passed through deliberately: the element clamps them itself, and\n * clamping twice would mean this file carries a second opinion about the duration.\n */\nexport function seekMedia(\n  media: Pick<HTMLMediaElement, \"currentTime\"> | null | undefined,\n  time: number\n): boolean {\n  if (!media || !Number.isFinite(time)) return false\n  media.currentTime = time\n  return true\n}\n\n/**\n * `seconds` as a clock reading: `0:07`, `1:12`, `1:02:03`.\n *\n * Non-finite input gives the placeholder rather than `NaN:NaN`, which is the state the component is\n * in on its first paint every single time.\n *\n * Seconds are floored, not rounded, so the reading never shows a time the media has not reached —\n * a clip ending at `0:59.6` that displays `1:00` beside a bar that has not filled looks broken, and\n * rounding up past a `duration` label is worse.\n */\nexport function formatTimecode(\n  seconds: number,\n  { placeholder = \"--:--\", forceHours = false }: { placeholder?: string; forceHours?: boolean } = {}\n): string {\n  if (!Number.isFinite(seconds)) return placeholder\n  const total = Math.floor(Math.max(0, seconds))\n  const hours = Math.floor(total / 3600)\n  const minutes = Math.floor((total % 3600) / 60)\n  const secs = total % 60\n  const pad = (n: number) => String(n).padStart(2, \"0\")\n  return hours > 0 || forceHours ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`\n}\n\n/**\n * `seconds` as words: `1 minute 12 seconds`.\n *\n * This is what `aria-valuetext` carries, and it is the reason a scrubber needs more than\n * `aria-valuenow`. A slider with no `valuetext` is announced as its number — \"72\" — and for a\n * position in a clip that is not merely terse but ambiguous: 72 of what? Percent, seconds, minutes\n * and \"72nd of 180 steps\" are all plausible readings of the same announcement, and the user cannot\n * tell which. Spelling out the unit is the only way the control says where it is.\n *\n * Units are written out rather than punctuated because a screen reader reads `1:02:03` as \"one\n * oh two oh three\" or \"one colon zero two\" depending on which one it is and what language it thinks\n * it is in. Zero-valued leading units are dropped — \"0 hours 1 minute\" is noise — but a zero\n * position still has to say something, so it reads \"0 seconds\".\n */\nexport function spokenTimecode(\n  seconds: number,\n  { placeholder = \"unknown\" }: { placeholder?: string } = {}\n): string {\n  if (!Number.isFinite(seconds)) return placeholder\n  const total = Math.floor(Math.max(0, seconds))\n  const hours = Math.floor(total / 3600)\n  const minutes = Math.floor((total % 3600) / 60)\n  const secs = total % 60\n  const parts: string[] = []\n  const unit = (value: number, name: string) => `${value} ${name}${value === 1 ? \"\" : \"s\"}`\n  if (hours > 0) parts.push(unit(hours, \"hour\"))\n  if (minutes > 0 || hours > 0) parts.push(unit(minutes, \"minute\"))\n  parts.push(unit(secs, \"second\"))\n  return parts.join(\" \")\n}\n\nexport interface MediaScrubberLabels {\n  /** The slider's accessible name. */\n  slider: string\n  /** Builds the `aria-valuetext`, given both sides already spoken. */\n  position: (spokenPosition: string, spokenDuration: string | null) => string\n  /** Announced in place of a position while the length is unknown. */\n  live: string\n}\n\nexport const defaultMediaScrubberLabels: MediaScrubberLabels = {\n  slider: \"Seek\",\n  position: (position, duration) => (duration ? `${position} of ${duration}` : position),\n  live: \"Live\",\n}\n\nexport interface UseMediaScrubberOptions {\n  /**\n   * A length to use instead of the element's own.\n   *\n   * For the `Infinity` case that is not live: a recording from `MediaRecorder` never learns its own\n   * length, but whoever recorded it measured one. Without this the scrubber for a voice note is\n   * permanently inert, which is the state `audio-recorder` in this registry works around by\n   * printing its measured duration beside a native control it cannot fix.\n   */\n  duration?: number\n  /** Seconds per arrow key. Defaults to {@link DEFAULT_SEEK_STEP}. */\n  step?: number\n  /** Seconds per Page Up / Page Down. Defaults to {@link DEFAULT_SEEK_STEP_LARGE}. */\n  largeStep?: number\n  /**\n   * Whether dragging seeks continuously or only on release. Defaults to `\"live\"`.\n   *\n   * `\"release\"` is for video over a slow network, where every intermediate seek is a keyframe fetch\n   * the user never sees.\n   */\n  scrub?: \"live\" | \"release\"\n  /**\n   * Whether to read the position on every animation frame while playing. Defaults to `true`.\n   *\n   * Turning it off leaves the bar on `timeupdate` alone, which is correct but steps four times a\n   * second. See {@link DEFAULT_TIME_STEPS}.\n   */\n  smooth?: boolean\n  /** Positions across the track worth a render. Defaults to {@link DEFAULT_TIME_STEPS}. */\n  timeSteps?: number\n  /** Overrides for the announced strings. */\n  labels?: Partial<MediaScrubberLabels>\n  /** Called with the committed time whenever a seek is issued from this control. */\n  onSeek?: (time: number) => void\n}\n\nexport interface MediaScrubberState {\n  /** The element's own position, ignoring any drag in progress. */\n  currentTime: number\n  /** What the control should display: the dragged position while dragging, else {@link currentTime}. */\n  displayTime: number\n  /** The effective length — the `duration` option when given, else the element's. */\n  duration: number\n  /** Whether {@link duration} can be used as a denominator. */\n  hasDuration: boolean\n  /** Whether the element reports an unbounded length and no override was given. */\n  live: boolean\n  buffered: TimeRange[]\n  seekable: TimeRange[]\n  paused: boolean\n  ended: boolean\n  /** Whether the element is working on a seek of its own. */\n  seeking: boolean\n  /** Whether a pointer is currently dragging this control. */\n  dragging: boolean\n  /** 0–1 position of {@link displayTime}. */\n  progress: number\n  /** 0–1 position of the end of the buffer continuous with {@link displayTime}. */\n  bufferedProgress: number\n  /** Whether this control can issue a seek at all. */\n  interactive: boolean\n}\n\nexport interface UseMediaScrubberResult extends MediaScrubberState {\n  /** Props for the element carrying `role=\"slider\"`. Spread onto the track. */\n  sliderProps: MediaScrubberSliderProps\n  /** Attach to the track element so the pointer handlers can measure it. */\n  trackRef: React.RefObject<HTMLDivElement | null>\n  /** Seek the element, with the finiteness guard. */\n  seek: (time: number) => void\n  labels: MediaScrubberLabels\n}\n\nexport interface MediaScrubberSliderProps {\n  role: \"slider\"\n  tabIndex: number\n  \"aria-label\": string\n  \"aria-orientation\": \"horizontal\"\n  \"aria-valuemin\": number | undefined\n  \"aria-valuemax\": number | undefined\n  \"aria-valuenow\": number | undefined\n  \"aria-valuetext\": string\n  \"aria-disabled\": boolean | undefined\n  onPointerDown: React.PointerEventHandler<HTMLElement>\n  onPointerMove: React.PointerEventHandler<HTMLElement>\n  onPointerUp: React.PointerEventHandler<HTMLElement>\n  onPointerCancel: React.PointerEventHandler<HTMLElement>\n  onKeyDown: React.KeyboardEventHandler<HTMLElement>\n}\n\nconst NO_RANGES: TimeRange[] = []\n\n/**\n * Tracks a media element's position and turns a track element into a seek control.\n *\n * The element stays the caller's: this subscribes to one they already have, rather than rendering\n * an `<audio>` or `<video>` of its own. A scrubber that owns the media element is a player, and a\n * player is the wrong unit — it decides the transport controls, the layout and the sources, none of\n * which a position bar has an opinion about.\n *\n * ### The two states, and why there are two\n *\n * While a drag is in progress the displayed position comes from the pointer, not from the element.\n * This is the one piece of state in the file that duplicates something the platform already knows,\n * and it is not an optimisation. Seeking is asynchronous: the element accepts `currentTime`, starts\n * work, and keeps reporting the *old* position until the seek completes. Render straight from\n * `currentTime` during a drag and every frame paints a thumb slightly behind the finger, which then\n * snaps forward — so the thumb lags, wobbles, and crawls out from under the pointer on a slow\n * source. The pointer is the truth about where the user is pointing; the element is the truth about\n * what is playing. They are only the same thing once the drag ends, and that is when this hands the\n * position back.\n *\n * What it deliberately does *not* do is run its own clock. The frame loop re-reads\n * `media.currentTime` every time; it never adds elapsed milliseconds to a previous reading. An\n * accumulated clock drifts against the audio immediately (the two are driven by different hardware)\n * and diverges without bound in a throttled background tab.\n */\nexport function useMediaScrubber(\n  mediaRef: React.RefObject<HTMLMediaElement | null>,\n  {\n    duration: durationOverride,\n    step = DEFAULT_SEEK_STEP,\n    largeStep = DEFAULT_SEEK_STEP_LARGE,\n    scrub = \"live\",\n    smooth = true,\n    timeSteps = DEFAULT_TIME_STEPS,\n    labels: labelOverrides,\n    onSeek,\n  }: UseMediaScrubberOptions = {}\n): UseMediaScrubberResult {\n  const trackRef = React.useRef<HTMLDivElement | null>(null)\n\n  const [currentTime, setCurrentTime] = React.useState(0)\n  const [elementDuration, setElementDuration] = React.useState(Number.NaN)\n  const [buffered, setBuffered] = React.useState<TimeRange[]>(NO_RANGES)\n  const [seekable, setSeekable] = React.useState<TimeRange[]>(NO_RANGES)\n  const [paused, setPaused] = React.useState(true)\n  const [ended, setEnded] = React.useState(false)\n  const [seeking, setSeeking] = React.useState(false)\n  const [dragTime, setDragTime] = React.useState<number | null>(null)\n\n  // The pointer that owns the drag. Everything else is ignored: a second finger landing on the\n  // track, or the first one being released, must not move or end a drag it did not start.\n  const dragPointer = React.useRef<number | null>(null)\n\n  const duration = isSeekableDuration(durationOverride) ? durationOverride : elementDuration\n  const hasDuration = isSeekableDuration(duration)\n  const live = !hasDuration && isLiveDuration(elementDuration)\n  const dragging = dragTime !== null\n  const displayTime = dragging ? dragTime : currentTime\n  // A usable length is the floor; `seekable` can only take that away, and only when it has\n  // something to say. An empty `seekable` is not evidence of anything (see {@link canSeekRanges}) —\n  // and it is the reading for a `MediaRecorder` clip, which is exactly the case the `duration`\n  // override exists to rescue, so treating empty as \"cannot seek\" would disable the one control\n  // that override was added for.\n  const interactive = hasDuration && (seekable.length === 0 || canSeekRanges(seekable))\n\n  const onSeekRef = React.useRef(onSeek)\n  React.useEffect(() => {\n    onSeekRef.current = onSeek\n  }, [onSeek])\n\n  const seek = React.useCallback(\n    (time: number) => {\n      if (seekMedia(mediaRef.current, time)) onSeekRef.current?.(time)\n    },\n    [mediaRef]\n  )\n\n  // One subscription for everything the element reports. Each event is here because it is the only\n  // one that fires for a state this control draws:\n  //   - `durationchange` is the only notice that `NaN` became a number, and it fires again when a\n  //     stream's length is revised. `loadedmetadata` alone misses the revision.\n  //   - `progress` is the buffer growing. `timeupdate` does not imply it and vice versa: a paused\n  //     element downloads without advancing, and a fully buffered one advances without downloading.\n  //   - `seeking`/`seeked` bracket the element's own work, including seeks from somewhere else\n  //     entirely (a transport button, a chapter link, the keyboard on a native control).\n  //   - `emptied` is the source being torn down or replaced, after which every reading is stale and\n  //     the duration is `NaN` again. Without it a scrubber keeps showing the last clip's length.\n  React.useEffect(() => {\n    const media = mediaRef.current\n    if (!media) return\n\n    const readTime = () => setCurrentTime(media.currentTime)\n    const readDuration = () => setElementDuration(media.duration)\n    // Both go through the identity bail-out, so an unchanged buffer costs no render. See\n    // {@link sameRanges} — `progress` alone fires often enough for this to matter.\n    const keepSame = (next: TimeRange[]) => (previous: TimeRange[]) =>\n      sameRanges(previous, next) ? previous : next\n    const readBuffered = () => setBuffered(keepSame(readTimeRanges(media.buffered)))\n    const readSeekable = () => setSeekable(keepSame(readTimeRanges(media.seekable)))\n    const readPlayback = () => {\n      setPaused(media.paused)\n      setEnded(media.ended)\n    }\n\n    const syncAll = () => {\n      readTime()\n      readDuration()\n      readBuffered()\n      readSeekable()\n      readPlayback()\n    }\n\n    // The element may already be loaded and playing when this mounts — a remount, a tab coming\n    // back, or simply a scrubber added beside a running player. Reading once up front is what makes\n    // the first paint show the real position instead of zero.\n    syncAll()\n\n    const onSeekingStart = () => {\n      setSeeking(true)\n      readTime()\n    }\n    const onSeeked = () => {\n      setSeeking(false)\n      readTime()\n      readBuffered()\n    }\n    const onEmptied = () => {\n      setCurrentTime(0)\n      setElementDuration(Number.NaN)\n      setBuffered(keepSame(NO_RANGES))\n      setSeekable(keepSame(NO_RANGES))\n      setSeeking(false)\n      readPlayback()\n    }\n\n    const listeners: Array<[string, EventListener]> = [\n      [\"timeupdate\", readTime],\n      [\"durationchange\", readDuration],\n      [\"loadedmetadata\", syncAll],\n      [\"progress\", readBuffered],\n      [\"seeking\", onSeekingStart],\n      [\"seeked\", onSeeked],\n      [\"play\", readPlayback],\n      [\"playing\", readPlayback],\n      [\"pause\", readPlayback],\n      [\"ended\", readPlayback],\n      [\"emptied\", onEmptied],\n    ]\n    for (const [type, listener] of listeners) media.addEventListener(type, listener)\n    return () => {\n      for (const [type, listener] of listeners) media.removeEventListener(type, listener)\n    }\n  }, [mediaRef])\n\n  // The frame loop. Runs only while the element is actually advancing, so a paused player costs\n  // nothing, and stops while dragging because the displayed position belongs to the pointer then —\n  // committing readings underneath a drag is exactly the fight described on the hook.\n  React.useEffect(() => {\n    if (!smooth || paused || ended || dragging) return\n    if (typeof requestAnimationFrame !== \"function\") return\n    const media = mediaRef.current\n    if (!media) return\n\n    let frame = requestAnimationFrame(function tick() {\n      setCurrentTime((previous) =>\n        shouldCommitTime(previous, media.currentTime, duration, timeSteps) ? media.currentTime : previous\n      )\n      frame = requestAnimationFrame(tick)\n    })\n    return () => cancelAnimationFrame(frame)\n  }, [smooth, paused, ended, dragging, duration, timeSteps, mediaRef])\n\n  // `null` for \"there is no measurement\", kept distinct from the time 0 that a measurement of a\n  // zero-width track legitimately produces. The caller has to be able to tell them apart: seeking\n  // to 0 because the track has not been laid out yet would send a half-played clip back to the\n  // start on the first press.\n  const timeAtPointer = React.useCallback(\n    (clientX: number): number | null => {\n      const track = trackRef.current\n      // Optional call rather than a plain one: a track rendered on the server, or under a test\n      // renderer with no layout, has no `getBoundingClientRect` to call at all.\n      const rect = track?.getBoundingClientRect?.()\n      if (!rect || !(rect.width > 0)) return null\n      return timeFromPointer({\n        clientX,\n        rect: { left: rect.left, width: rect.width },\n        duration,\n        rtl: isRtlElement(track),\n      })\n    },\n    [duration]\n  )\n\n  const onPointerDown = React.useCallback<React.PointerEventHandler<HTMLElement>>(\n    (event) => {\n      // Primary button only. A right-click opening a context menu must not start a drag that then\n      // has no release to end it, and a middle-click must not seek.\n      if (!interactive || event.button !== 0) return\n      const time = timeAtPointer(event.clientX)\n      if (time === null) return\n      dragPointer.current = event.pointerId\n      setDragTime(time)\n      // Capture, so a drag that leaves the track — which it will, because the gesture is a flick\n      // along a bar a few pixels tall — keeps delivering here instead of to whatever is underneath.\n      event.currentTarget.setPointerCapture?.(event.pointerId)\n      // The track takes focus on press so the keyboard path continues from where the pointer left\n      // off. Without it, a user who drags and then reaches for an arrow key moves the page instead.\n      event.currentTarget.focus?.()\n      if (scrub === \"live\") seek(time)\n    },\n    [interactive, timeAtPointer, scrub, seek]\n  )\n\n  const onPointerMove = React.useCallback<React.PointerEventHandler<HTMLElement>>(\n    (event) => {\n      if (dragPointer.current !== event.pointerId) return\n      const time = timeAtPointer(event.clientX)\n      if (time === null) return\n      setDragTime(time)\n      if (scrub === \"live\") seek(time)\n    },\n    [timeAtPointer, scrub, seek]\n  )\n\n  const endDrag = React.useCallback(\n    (event: React.PointerEvent<HTMLElement>, commit: boolean) => {\n      if (dragPointer.current !== event.pointerId) return\n      dragPointer.current = null\n      // The release event's own coordinate, not the last `pointermove`'s. Moves are coalesced, so\n      // the final one before release is routinely dropped — committing it lands the seek a few\n      // pixels behind where the user let go, which on an hour-long recording is seconds.\n      //\n      // An unmeasurable track skips the commit but never the teardown below: returning early here\n      // is what leaves a drag that can only be ended by reloading the page.\n      const time = commit ? timeAtPointer(event.clientX) : null\n      if (time !== null) seek(time)\n      event.currentTarget.releasePointerCapture?.(event.pointerId)\n      // Cleared last. The element needs a frame to report the new position, and dropping the drag\n      // value before then paints one frame of the old position — a visible snap backwards at the\n      // end of every drag.\n      setDragTime(null)\n    },\n    [seek, timeAtPointer]\n  )\n\n  const onPointerUp = React.useCallback<React.PointerEventHandler<HTMLElement>>(\n    (event) => endDrag(event, true),\n    [endDrag]\n  )\n\n  // Cancel is not a release: the gesture was taken over by the browser (a scroll, a back-swipe, the\n  // page being hidden). Committing a position the user never chose is the wrong answer, so the drag\n  // is abandoned and the element keeps whatever it had.\n  const onPointerCancel = React.useCallback<React.PointerEventHandler<HTMLElement>>(\n    (event) => endDrag(event, false),\n    [endDrag]\n  )\n\n  const onKeyDown = React.useCallback<React.KeyboardEventHandler<HTMLElement>>(\n    (event) => {\n      if (!interactive) return\n      const target = keyboardSeekTarget({\n        key: event.key,\n        currentTime: displayTime,\n        duration,\n        step,\n        largeStep,\n        rtl: isRtlElement(event.currentTarget),\n      })\n      if (target === null) return\n      // Only for keys that were handled — Arrow and Page keys scroll the page, Home and End jump it,\n      // and all of them must stop here. Tab and Escape must not.\n      event.preventDefault()\n      seek(target)\n    },\n    [interactive, displayTime, duration, step, largeStep, seek]\n  )\n\n  const labels = React.useMemo(\n    () => ({ ...defaultMediaScrubberLabels, ...labelOverrides }),\n    [labelOverrides]\n  )\n  const progress = fractionOfDuration(displayTime, duration)\n  const bufferedProgress = Math.max(\n    progress,\n    fractionOfDuration(bufferedEndAt(buffered, displayTime), duration)\n  )\n\n  const sliderProps: MediaScrubberSliderProps = {\n    role: \"slider\",\n    // Focusable even when it cannot be used, so a keyboard user can reach it and hear why. A control\n    // that vanishes from the tab order while a clip loads is a control that moves under the user.\n    tabIndex: 0,\n    \"aria-label\": labels.slider,\n    \"aria-orientation\": \"horizontal\",\n    // All three value attributes are withheld together while the length is unknown, because they\n    // only mean anything as a set. A `slider` with no `aria-valuemax` is defined to have one of 100,\n    // so publishing `valuenow=\"0\"` on its own states that playback is at 0% — a precise, confident\n    // claim about a position nobody knows yet. An `aria-disabled` slider carrying no value says the\n    // true thing instead, and `aria-valuetext` still carries the reason in words.\n    \"aria-valuemin\": hasDuration ? 0 : undefined,\n    \"aria-valuemax\": hasDuration ? duration : undefined,\n    \"aria-valuenow\": hasDuration ? displayTime : undefined,\n    \"aria-valuetext\": hasDuration\n      ? labels.position(spokenTimecode(displayTime), spokenTimecode(duration))\n      : live\n        ? labels.live\n        : labels.position(spokenTimecode(Number.NaN), null),\n    \"aria-disabled\": interactive ? undefined : true,\n    onPointerDown,\n    onPointerMove,\n    onPointerUp,\n    onPointerCancel,\n    onKeyDown,\n  }\n\n  return {\n    currentTime,\n    displayTime,\n    duration,\n    hasDuration,\n    live,\n    buffered,\n    seekable,\n    paused,\n    ended,\n    seeking,\n    dragging,\n    progress,\n    bufferedProgress,\n    interactive,\n    sliderProps,\n    trackRef,\n    seek,\n    labels,\n  }\n}\n\nexport interface MediaScrubberProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\">,\n    UseMediaScrubberOptions {\n  /** The media element to follow and seek. */\n  mediaRef: React.RefObject<HTMLMediaElement | null>\n  /** Whether to show the elapsed and total readings under the bar. Defaults to `true`. */\n  withTimes?: boolean\n}\n\n/**\n * A playback position bar for a media element the caller owns.\n *\n * ```tsx\n * const audio = React.useRef<HTMLAudioElement>(null)\n * return (\n *   <>\n *     <audio ref={audio} src={src} />\n *     <MediaScrubber mediaRef={audio} />\n *   </>\n * )\n * ```\n *\n * For any layout other than this one, call {@link useMediaScrubber} directly and spread its\n * `sliderProps` onto whatever draws the track — the hook is the whole behaviour, and this component\n * is one arrangement of it.\n */\nexport function MediaScrubber({\n  mediaRef,\n  duration: durationOverride,\n  step,\n  largeStep,\n  scrub,\n  smooth,\n  timeSteps,\n  labels: labelOverrides,\n  onSeek,\n  withTimes = true,\n  className,\n  ...props\n}: MediaScrubberProps) {\n  const {\n    sliderProps,\n    trackRef,\n    progress,\n    bufferedProgress,\n    displayTime,\n    duration,\n    hasDuration,\n    live,\n    dragging,\n    labels,\n  } = useMediaScrubber(mediaRef, {\n    duration: durationOverride,\n    step,\n    largeStep,\n    scrub,\n    smooth,\n    timeSteps,\n    labels: labelOverrides,\n    onSeek,\n  })\n\n  return (\n    <div className={cn(\"flex w-full flex-col gap-1.5\", className)} {...props}>\n      <div\n        {...sliderProps}\n        ref={trackRef}\n        className={cn(\n          // `touch-none` is what makes a drag along the bar a drag and not a page scroll: without\n          // it the browser claims the gesture and fires `pointercancel`, so the scrubber works with\n          // a mouse and does nothing at all on a phone.\n          \"group relative h-6 w-full touch-none select-none\",\n          \"focus-visible:outline-none\",\n          sliderProps[\"aria-disabled\"] ? \"cursor-default\" : \"cursor-pointer\"\n        )}\n      >\n        {/* The visible bar is inset inside a 24px-tall box: the pointer target has to be big enough\n            to hit, and a 4px bar is not. Hit area and drawn height are deliberately different.\n\n            The focus ring is drawn here rather than on the thumb. The thumb is the prettier place\n            for it, but it is hidden whenever there is no position to point at — and that is exactly\n            the state the track is still deliberately focusable in, so a keyboard user tabbing onto\n            an unloaded clip would land on a control with no visible focus at all. The bar is the\n            one part that is always drawn. */}\n        <div\n          className={cn(\n            \"absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-secondary\",\n            \"ring-ring ring-offset-2 ring-offset-background group-focus-visible:ring-2\"\n          )}\n        >\n          <div\n            className=\"absolute inset-y-0 left-0 bg-muted-foreground/40 transition-[width] duration-200 ease-out\"\n            style={{ width: `${bufferedProgress * 100}%` }}\n          />\n          <div\n            className={cn(\n              \"absolute inset-y-0 left-0 rounded-full bg-primary\",\n              // Only the untouched bar animates its width. During a drag the width *is* the pointer\n              // position, and a transition on it adds a lag the user reads as the control sticking.\n              !dragging && \"transition-[width] duration-100 ease-out\"\n            )}\n            style={{ width: `${progress * 100}%` }}\n          />\n        </div>\n        <div\n          aria-hidden=\"true\"\n          className={cn(\n            \"pointer-events-none absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background bg-primary shadow-sm\",\n            \"transition-[opacity,scale]\",\n            dragging ? \"scale-110\" : \"scale-100\",\n            // Hidden until there is a position to point at, so an unloaded clip shows a plain bar\n            // instead of a thumb parked at zero that cannot be moved.\n            hasDuration ? \"opacity-100\" : \"opacity-0\"\n          )}\n          style={{ left: `${progress * 100}%` }}\n        />\n      </div>\n      {withTimes ? (\n        <div className=\"flex items-center justify-between text-xs tabular-nums text-muted-foreground\">\n          {/* `aria-hidden`, because the slider already announces its position through\n              `aria-valuetext`. Left readable, these two make every seek announce the time twice. */}\n          <span aria-hidden=\"true\">{formatTimecode(displayTime)}</span>\n          <span aria-hidden=\"true\">{live ? labels.live : formatTimecode(duration)}</span>\n        </div>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
