# Scrubber

> A compact player: play and pause, the position, a seek bar and the length.

```tsx
"use client";

import { createSource } from "@audio-ui/react";
import { useCallback, useEffect, useState } from "react";
import { Scrubber } from "@/components/audio-ui/scrubber";

const DURATION = 215;

/** A clock standing in for a player: it plays, you seek, and the bar and the time follow it. */
export function ScrubberDemo() {
  const [position] = useState(() => createSource(42));
  const [playing, setPlaying] = useState(false);

  useEffect(() => {
    if (!playing) {
      return;
    }
    let frame = 0;
    let last = performance.now();
    const tick = (now: number) => {
      const next = position.getSnapshot() + (now - last) / 1000;
      last = now;
      if (next >= DURATION) {
        position.set(0);
        setPlaying(false);
        return;
      }
      position.set(next);
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [playing, position]);

  const seek = useCallback(
    (seconds: number) => position.set(seconds),
    [position]
  );

  return (
    <div className="w-full">
      <Scrubber
        duration={DURATION}
        onPlayingChange={setPlaying}
        onSeek={seek}
        playing={playing}
        source={position}
      />
    </div>
  );
}
```

## Installation

**CLI**

```bash
npx shadcn@latest add @audio-ui/scrubber
```

**Manual**

**1. Install the following dependencies:**

```bash
npm install @audio-ui/react
```

**2. Copy and paste the following code into your project.**

```tsx title="components/audio-ui/scrubber.tsx"
"use client";

import { type Source, Timeline as TimelinePrimitive } from "@audio-ui/react";
import { useCallback, useEffect, useRef } from "react";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";

/** The thumb stays inside the bar, as a slider's does: at either end its edge meets the bar's. */
const THUMB_SIZE = "0.875rem";

/** 75 as "1:15", 3725 as "1:02:05". */
function formatClock(seconds: number): string {
  const total = Math.max(0, Math.floor(seconds));
  const hours = Math.floor(total / 3600);
  const minutes = Math.floor((total % 3600) / 60);
  const rest = String(total % 60).padStart(2, "0");
  return hours > 0
    ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}`
    : `${minutes}:${rest}`;
}

/** The position as a clock, written straight into the DOM as the source moves. */
function ScrubberTime({
  className,
  source,
}: {
  className?: string;
  source: Source<number>;
}) {
  const ref = useRef<HTMLSpanElement>(null);
  useEffect(() => {
    const node = ref.current;
    if (!node) {
      return;
    }
    // One text node, rewritten in place: replacing the node's children on every update makes the browser recheck every :has() and :empty rule on the page.
    const text = node.appendChild(document.createTextNode(""));
    const paint = () => {
      text.data = formatClock(source.getSnapshot());
    };
    paint();
    const unsubscribe = source.subscribe(paint);
    return () => {
      unsubscribe();
      text.remove();
    };
  }, [source]);
  return (
    <span
      className={cn("min-w-10 tabular-nums", className)}
      data-slot="scrubber-time"
      ref={ref}
    />
  );
}

interface ScrubberProps {
  "aria-label"?: string;
  className?: string;
  /** Length of the track, in seconds. */
  duration: number;
  /** Called when the play button is pressed, with whether playback should now run. */
  onPlayingChange: (playing: boolean) => void;
  /** Called when the bar is pressed, dragged or moved by key, with the new position in seconds. */
  onSeek?: (seconds: number) => void;
  /** Whether it is playing: the button shows pause. */
  playing: boolean;
  /** The playback position in seconds, a live value: the bar and the clock follow it without re-rendering. */
  source: Source<number>;
}

/**
 * Play or pause, the position, a bar to seek on, and the length: the controls
 * under a player or an editor. The bar is a `Timeline` ruler, so a press jumps
 * to the pointer, a drag scrubs (four times finer with Shift), and the arrow
 * keys, Page Up/Down, Home and End move it.
 */
function Scrubber({
  "aria-label": ariaLabel = "Playback position",
  className,
  duration,
  onPlayingChange,
  onSeek,
  playing,
  source,
}: ScrubberProps) {
  const toggle = useCallback(
    () => onPlayingChange(!playing),
    [onPlayingChange, playing]
  );
  return (
    <div
      className={cn("flex w-full min-w-0 items-center gap-3", className)}
      data-slot="scrubber"
      role="group"
    >
      <Button
        aria-label={playing ? "Pause" : "Play"}
        onClick={toggle}
        size="icon"
        variant="outline"
      >
        {playing ? (
          <IconPlaceholder
            hugeicons="PauseIcon"
            lucide="PauseIcon"
            phosphor="PauseIcon"
            remixicon="RiPauseLine"
            tabler="IconPlayerPause"
          />
        ) : (
          <IconPlaceholder
            hugeicons="PlayIcon"
            lucide="PlayIcon"
            phosphor="PlayIcon"
            remixicon="RiPlayLine"
            tabler="IconPlayerPlay"
          />
        )}
      </Button>
      <ScrubberTime
        className="text-right text-muted-foreground text-xs"
        source={source}
      />
      <TimelinePrimitive.Root
        className="min-w-0 flex-1"
        duration={duration}
        onPositionChange={onSeek}
        source={source}
        step={1}
      >
        <TimelinePrimitive.Ruler
          aria-label={ariaLabel}
          className="rounded-full bg-muted has-focus-visible:ring-2 has-focus-visible:ring-ring/50 relative h-1.5 cursor-pointer touch-none"
          getAriaValueText={formatClock}
        >
          <TimelinePrimitive.Playhead
            className="rounded-full bg-primary pointer-events-none absolute inset-y-0"
            style={{
              left: 0,
              width: `calc(var(--timeline-unit) * var(--timeline-offset) - ${THUMB_SIZE} * var(--timeline-offset) / ${duration} + ${THUMB_SIZE} / 2)`,
            }}
          />
          <TimelinePrimitive.Playhead
            className="rounded-full border-2 border-primary bg-background shadow-xs pointer-events-none absolute top-1/2 -translate-y-1/2"
            style={{
              height: THUMB_SIZE,
              left: `calc(var(--timeline-unit) * var(--timeline-offset) - ${THUMB_SIZE} * var(--timeline-offset) / ${duration})`,
              width: THUMB_SIZE,
            }}
          />
        </TimelinePrimitive.Ruler>
      </TimelinePrimitive.Root>
      <span className="min-w-10 text-muted-foreground text-xs tabular-nums">
        {formatClock(duration)}
      </span>
    </div>
  );
}

export { formatClock, Scrubber };
```

**3. Update the import paths to match your project setup.**

## Anatomy

The scrubber is a Timeline: its bar is the ruler, with a playhead for the fill and one for the thumb.

```tsx
import { Timeline } from "@audio-ui/react";

<Timeline.Root>
  <Timeline.Ruler>
    <Timeline.Playhead /> {/* the fill */}
    <Timeline.Playhead /> {/* the thumb */}
  </Timeline.Ruler>
</Timeline.Root>
```

## Usage

```tsx
import { createSource } from "@audio-ui/react";
import { Scrubber } from "@/components/audio-ui/scrubber";

const position = createSource(0); // seconds; set it from your player's clock

<Scrubber
  duration={215}
  onPlayingChange={(playing) => (playing ? player.play() : player.pause())}
  onSeek={(seconds) => player.seek(seconds)}
  playing={isPlaying}
  source={position}
/>;
```

## Keyboard

The bar holds a native `<input type="range">`: the arrow keys move it by a second, `Page Up` and `Page Down` by ten, `Home` and `End` go to the ends. It announces the position as a clock, such as "1:15".

## API reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `{ getSnapshot, subscribe }` |  | The playback position in seconds, a live value. |
| `duration` | `number` |  | Length of the track, in seconds. |
| `playing` | `boolean` |  | Whether it is playing: the button shows pause. |
| `onPlayingChange` | `(playing: boolean) => void` |  | The play button was pressed. |
| `onSeek` | `(seconds: number) => void` |  | The bar was pressed, dragged or moved by key. |
| `aria-label` | `string` |  | Names the bar. Defaults to "Playback position". |
