An audio player with an SVG waveform scrub bar, a morphing play/pause control, and tabular current and total times.
npx shadcn@latest add @paragon/audio-player"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>
);
}