Media

Audio Player

An audio player with an SVG waveform scrub bar, a morphing play/pause control, and tabular current and total times.

Install

npx shadcn@latest add @paragon/audio-player

audio-player.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Pause, Play } from "lucide-react";
import { cn } from "@/lib/utils";

export interface AudioPlayerProps extends React.ComponentProps<"div"> {
  title?: string;
  subtitle?: string;
  /** Bar heights, 0–1, one per waveform bar. */
  waveform?: number[];
  /** Total duration in seconds. */
  duration?: number;
  /** Renders press without scale, icon swaps without morph. */
  static?: boolean;
}

// Deterministic default waveform — a smooth pseudo-random envelope, no RNG.
const DEFAULT_WAVE = Array.from({ length: 56 }, (_, i) => {
  const a = Math.sin(i * 0.5) * 0.5 + 0.5;
  const b = Math.sin(i * 0.17 + 1.3) * 0.5 + 0.5;
  return 0.2 + ((a * 0.6 + b * 0.4) * 0.8);
});

function formatTime(seconds: number) {
  const s = Math.max(0, Math.floor(seconds));
  const m = Math.floor(s / 60);
  const r = s % 60;
  return `${m}:${r.toString().padStart(2, "0")}`;
}

/**
 * An audio player with an SVG waveform scrub bar. Bars past the play head fill
 * to the foreground; the rest stay muted, and clicking or dragging the track
 * seeks (pointer-captured, keyboard-seekable via a hidden range). The
 * play/pause control morphs its icon with the house icon-swap. Times are
 * tabular so the layout never shifts. Progress is driven by a rAF ticker that
 * pauses when the tab is hidden; reduced motion drops the icon morph.
 */
export function AudioPlayer({
  title = "Weekly Standup Recording",
  subtitle = "Engineering — 24 min",
  waveform = DEFAULT_WAVE,
  duration = 1464,
  static: isStatic = false,
  className,
  ...props
}: AudioPlayerProps) {
  const reducedMotion = useReducedMotion();
  const [playing, setPlaying] = React.useState(false);
  const [progress, setProgress] = React.useState(0.28); // 0–1
  const trackRef = React.useRef<HTMLDivElement>(null);
  const raf = React.useRef<number>(0);
  const last = React.useRef<number>(0);

  React.useEffect(() => {
    if (!playing) return;
    const tick = (t: number) => {
      if (last.current) {
        const dt = (t - last.current) / 1000;
        setProgress((p) => {
          const next = p + dt / duration;
          if (next >= 1) {
            setPlaying(false);
            return 1;
          }
          return next;
        });
      }
      last.current = t;
      raf.current = requestAnimationFrame(tick);
    };
    raf.current = requestAnimationFrame(tick);
    const onVisibility = () => {
      if (document.hidden) last.current = 0;
    };
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      cancelAnimationFrame(raf.current);
      last.current = 0;
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, [playing, duration]);

  const seekFromClientX = (clientX: number) => {
    const rect = trackRef.current?.getBoundingClientRect();
    if (!rect) return;
    const next = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
    setProgress(next);
  };

  const current = progress * duration;

  return (
    <div
      data-slot="audio-player"
      className={cn(
        "flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-center gap-3">
        <button
          type="button"
          aria-label={playing ? "Pause" : "Play"}
          onClick={() => setPlaying((p) => !p)}
          className={cn(
            "flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground outline-none transition-[scale] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
            !isStatic && "active:scale-[0.95]",
          )}
        >
          {isStatic || reducedMotion ? (
            playing ? <Pause className="size-4" /> : <Play className="size-4" />
          ) : (
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={playing ? "pause" : "play"}
                initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
                animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
                transition={{ type: "spring", duration: 0.3, bounce: 0 }}
                className="flex"
              >
                {playing ? <Pause className="size-4" /> : <Play className="size-4" />}
              </motion.span>
            </AnimatePresence>
          )}
        </button>

        <div className="min-w-0 flex-1">
          <div className="truncate text-sm font-medium">{title}</div>
          <div className="truncate text-xs text-muted-foreground">{subtitle}</div>
        </div>
      </div>

      <div className="flex items-center gap-3">
        <span className="w-9 shrink-0 text-right font-mono text-xs tabular-nums text-muted-foreground">
          {formatTime(current)}
        </span>

        <div
          ref={trackRef}
          role="slider"
          aria-label="Seek"
          aria-valuemin={0}
          aria-valuemax={100}
          aria-valuenow={Math.round(progress * 100)}
          tabIndex={0}
          onPointerDown={(e) => {
            e.currentTarget.setPointerCapture(e.pointerId);
            seekFromClientX(e.clientX);
          }}
          onPointerMove={(e) => {
            if (e.currentTarget.hasPointerCapture(e.pointerId)) {
              seekFromClientX(e.clientX);
            }
          }}
          onKeyDown={(e) => {
            if (e.key === "ArrowRight")
              setProgress((p) => Math.min(1, p + 0.02));
            if (e.key === "ArrowLeft")
              setProgress((p) => Math.max(0, p - 0.02));
          }}
          className="relative flex h-8 flex-1 cursor-pointer touch-none items-center gap-[2px] rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
        >
          {waveform.map((h, i) => {
            const played = i / waveform.length <= progress;
            return (
              <span
                key={i}
                aria-hidden
                className={cn(
                  "flex-1 rounded-full transition-colors duration-100",
                  played ? "bg-primary" : "bg-muted-foreground/25",
                )}
                style={{ height: `${Math.round(h * 100)}%` }}
              />
            );
          })}
        </div>

        <span className="w-9 shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
          {formatTime(duration)}
        </span>
      </div>
    </div>
  );
}