"use client";
import * as React from "react";
import { motion } from "motion/react";
export interface WaveformPlayerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
/** Waveform peaks, any positive scale (normalised to the largest value). One bar per entry. */
peaks: number[];
/** Audio file URL. Ignored when `synth` is on. */
src?: string;
/** Play a generated Web Audio melody instead of a file (no assets needed). */
synth?: boolean;
/** Length of the synth melody in seconds. */
synthDuration?: number;
/** Track title, shown above the waveform and used in labels. */
title?: string;
/** Secondary line, e.g. the artist. */
subtitle?: string;
/** Seconds moved by the arrow keys. */
seekStep?: number;
/** Waveform height in px. */
height?: number;
/** Gap between bars in px. */
barGap?: number;
/** Show elapsed / total time. */
showTime?: boolean;
variant?: "card" | "bare";
/** Played bars, play button and focus rings. */
accent?: string;
/** Called when playback starts or stops. */
onPlayChange?: (playing: boolean) => void;
}
/** Common interface for the <audio> element and the Web Audio synth. */
interface Engine {
play(): Promise<void>;
pause(): void;
seek(time: number): void;
time(): number;
destroy(): void;
}
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function formatTime(seconds: number) {
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = String(s % 60).padStart(2, "0");
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
}
/* ---------------------------------- synth ---------------------------------- */
// A minor pentatonic progression (Am, F, C, G), one chord per 2-second bar, sixteenth-note arpeggios.
const CHORDS = [
[57, 60, 64, 69],
[53, 57, 60, 65],
[48, 55, 60, 64],
[55, 59, 62, 67],
];
const PATTERN = [0, 1, 2, 3, 2, 1, 3, 2];
const STEP = 0.25; // seconds per note
const midiToHz = (midi: number) => 440 * Math.pow(2, (midi - 69) / 12);
function createSynth(duration: number, onEnded: () => void): Engine {
let ctx: AudioContext | null = null;
let bus: GainNode | null = null;
let voices: OscillatorNode[] = [];
let startedAt = 0; // context time that corresponds to position 0
let offset = 0; // position while paused
let playing = false;
let endTimer = 0;
const stopVoices = () => {
window.clearTimeout(endTimer);
if (!ctx || !bus) return;
const now = ctx.currentTime;
const old = bus;
old.gain.cancelScheduledValues(now);
old.gain.setValueAtTime(old.gain.value, now);
old.gain.linearRampToValueAtTime(0, now + 0.04);
for (const v of voices) {
try {
v.stop(now + 0.05);
} catch {
// already stopped
}
}
window.setTimeout(() => old.disconnect(), 120);
voices = [];
bus = null;
};
/** Schedules every note from `from` seconds onwards. */
const schedule = (from: number) => {
if (!ctx) return;
const now = ctx.currentTime + 0.03;
bus = ctx.createGain();
bus.gain.value = 0.55;
bus.connect(ctx.destination);
const out = bus;
const note = (time: number, midi: number, length: number, type: OscillatorType, level: number) => {
if (time + length < from || time >= duration || !ctx) return;
const at = now + Math.max(0, time - from);
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = type;
osc.frequency.value = midiToHz(midi);
env.gain.setValueAtTime(0.0001, at);
env.gain.exponentialRampToValueAtTime(level, at + 0.012);
env.gain.exponentialRampToValueAtTime(0.0001, at + length);
osc.connect(env).connect(out);
osc.start(at);
osc.stop(at + length + 0.02);
voices.push(osc);
};
const bars = Math.ceil(duration / (STEP * PATTERN.length));
for (let bar = 0; bar < bars; bar++) {
const chord = CHORDS[bar % CHORDS.length];
const barStart = bar * STEP * PATTERN.length;
note(barStart, chord[0] - 12, 1.9, "sine", 0.16);
PATTERN.forEach((index, i) => {
// Lift every other bar an octave for a little shape.
const lift = bar % 4 === 3 && i > 3 ? 12 : 0;
note(barStart + i * STEP, chord[index] + lift, 0.42, "triangle", i % 4 === 0 ? 0.12 : 0.08);
});
}
startedAt = now - from;
};
return {
async play() {
if (!ctx) {
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
ctx = new Ctor();
}
await ctx.resume();
stopVoices();
if (offset >= duration - 0.05) offset = 0;
schedule(offset);
playing = true;
endTimer = window.setTimeout(() => {
stopVoices();
playing = false;
offset = 0;
onEnded();
}, (duration - offset) * 1000);
},
pause() {
offset = this.time();
playing = false;
stopVoices();
},
seek(time) {
offset = Math.min(Math.max(0, time), duration);
if (playing) void this.play();
},
time() {
return playing && ctx ? Math.min(duration, Math.max(0, ctx.currentTime - startedAt)) : offset;
},
destroy() {
stopVoices();
playing = false;
void ctx?.close();
ctx = null;
},
};
}
function createAudioEngine(audio: HTMLAudioElement): Engine {
return {
play: () => audio.play(),
pause: () => audio.pause(),
seek: (time) => {
audio.currentTime = time;
},
time: () => audio.currentTime,
destroy: () => {
audio.pause();
audio.removeAttribute("src");
audio.load();
},
};
}
/* --------------------------------- player ---------------------------------- */
// Play / pause as two quads each, so the icon can morph between them point by point.
const PLAY = ["M7 4.5L13 8.25L13 15.75L7 19.5Z", "M13 8.25L19 12L19 12L13 15.75Z"];
const PAUSE = ["M6.5 5L10 5L10 19L6.5 19Z", "M14 5L17.5 5L17.5 19L14 19Z"];
/**
* A compact audio player with a bar waveform you can click or drag to scrub. The played portion is
* highlighted, hovering previews the seek position and time, and the waveform is a real slider
* (arrows seek, Space plays / pauses). Plays an audio `src`, or a generated Web Audio melody in
* `synth` mode. Never autoplays, and stops when unmounted.
*/
export function WaveformPlayer({
peaks,
src,
synth = false,
synthDuration = 32,
title,
subtitle,
seekStep = 5,
height = 48,
barGap = 2,
showTime = true,
variant = "card",
accent = "#FF7448",
onPlayChange,
className = "",
style,
...props
}: WaveformPlayerProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const waveRef = React.useRef<HTMLDivElement>(null);
const hoverLabelRef = React.useRef<HTMLSpanElement>(null);
const elapsedRef = React.useRef<HTMLSpanElement>(null);
const engineRef = React.useRef<Engine | null>(null);
const scrubbing = React.useRef(false);
const [playing, setPlaying] = React.useState(false);
const [position, setPosition] = React.useState(0); // whole seconds, for ARIA and the time label
const [loadedDuration, setLoadedDuration] = React.useState(0);
const duration = synth ? synthDuration : loadedDuration;
// How many bars fit at ≥ 1.5px each; extra peaks are merged (bucket max) so narrow layouts never overflow.
const [capacity, setCapacity] = React.useState(Infinity);
React.useEffect(() => {
const wave = waveRef.current;
if (!wave) return;
const ro = new ResizeObserver(([entry]) => setCapacity(Math.max(8, Math.floor((entry.contentRect.width + barGap) / (barGap + 1.5)))));
ro.observe(wave);
return () => ro.disconnect();
}, [barGap]);
const sampled =
peaks.length <= capacity
? peaks
: Array.from({ length: capacity }, (_, i) => {
const from = Math.floor((i * peaks.length) / capacity);
const to = Math.max(from + 1, Math.floor(((i + 1) * peaks.length) / capacity));
return Math.max(...peaks.slice(from, to));
});
const max = Math.max(0.0001, ...sampled);
const bars = sampled.map((p) => Math.round(Math.max(0.06, Math.min(1, p / max)) * 10000) / 100);
const setProgress = React.useCallback((fraction: number) => {
rootRef.current?.style.setProperty("--wp-p", String(Math.round(Math.min(1, Math.max(0, fraction)) * 10000) / 10000));
}, []);
const onPlayChangeRef = React.useRef(onPlayChange);
React.useEffect(() => {
onPlayChangeRef.current = onPlayChange;
});
// Create the engine for the current source; tear it down (and stop audio) on change / unmount.
React.useEffect(() => {
setProgress(0);
const ended = () => {
setPlaying(false);
setPosition(0);
setProgress(0);
onPlayChangeRef.current?.(false);
};
if (synth) {
const engine = createSynth(synthDuration, ended);
engineRef.current = engine;
return () => {
engine.destroy();
engineRef.current = null;
};
}
if (!src) return;
const audio = new Audio();
audio.preload = "metadata";
const onMeta = () => setLoadedDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
audio.addEventListener("loadedmetadata", onMeta);
audio.addEventListener("ended", ended);
audio.src = src;
const engine = createAudioEngine(audio);
engineRef.current = engine;
return () => {
audio.removeEventListener("loadedmetadata", onMeta);
audio.removeEventListener("ended", ended);
engine.destroy();
engineRef.current = null;
};
}, [src, synth, synthDuration, setProgress]);
// A new source starts stopped at 0:00.
const sourceKey = `${synth}:${synthDuration}:${src ?? ""}`;
const [prevSource, setPrevSource] = React.useState(sourceKey);
if (prevSource !== sourceKey) {
setPrevSource(sourceKey);
setPlaying(false);
setPosition(0);
}
// While playing (and on screen), move the progress every frame without re-rendering.
React.useEffect(() => {
const root = rootRef.current;
if (!playing || !root || duration <= 0) return;
let frame = 0;
let visible = true;
let lastSecond = -1;
const tick = () => {
const engine = engineRef.current;
if (engine && !scrubbing.current) {
const t = engine.time();
setProgress(t / duration);
const second = Math.floor(t);
if (second !== lastSecond) {
lastSecond = second;
setPosition(second);
}
}
frame = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
cancelAnimationFrame(frame);
if (visible) frame = requestAnimationFrame(tick);
});
io.observe(root);
frame = requestAnimationFrame(tick);
return () => {
io.disconnect();
cancelAnimationFrame(frame);
};
}, [playing, duration, setProgress]);
const toggle = async () => {
const engine = engineRef.current;
if (!engine) return;
if (playing) {
engine.pause();
setPlaying(false);
onPlayChange?.(false);
return;
}
try {
await engine.play();
setPlaying(true);
onPlayChange?.(true);
} catch {
// Playback was blocked or the source failed; stay paused.
setPlaying(false);
}
};
const seekTo = (time: number) => {
const t = Math.min(Math.max(0, time), duration);
engineRef.current?.seek(t);
setProgress(duration > 0 ? t / duration : 0);
setPosition(Math.floor(t));
};
const fractionAt = (clientX: number) => {
const rect = waveRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return 0;
return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
};
/** Hover / scrub preview: CSS variable + label text, no React state per move. */
const preview = (fraction: number) => {
const wave = waveRef.current;
if (!wave) return;
wave.style.setProperty("--wp-h", String(Math.round(fraction * 10000) / 10000));
if (hoverLabelRef.current) hoverLabelRef.current.textContent = formatTime(fraction * duration);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const current = engineRef.current?.time() ?? position;
let next: number | null = null;
switch (event.key) {
case " ":
case "k":
event.preventDefault();
void toggle();
return;
case "ArrowLeft":
case "ArrowDown":
next = current - seekStep;
break;
case "ArrowRight":
case "ArrowUp":
next = current + seekStep;
break;
case "PageDown":
next = current - duration / 10;
break;
case "PageUp":
next = current + duration / 10;
break;
case "Home":
next = 0;
break;
case "End":
next = duration;
break;
default:
return;
}
event.preventDefault();
seekTo(next);
};
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 30 };
const label = title ? `${title}${subtitle ? `, ${subtitle}` : ""}` : "Audio";
const total = formatTime(duration);
const barRow = (extra: string, clipVar?: string) => (
<div
aria-hidden="true"
className={`absolute inset-0 flex items-center ${extra}`}
style={{ gap: barGap, clipPath: clipVar ? `inset(0 calc(100% - var(${clipVar}, 0) * 100%) 0 0)` : undefined }}
>
{bars.map((h, i) => (
<span key={i} className="min-w-px flex-1 rounded-full bg-current" style={{ height: `${h}%` }} />
))}
</div>
);
return (
<div
ref={rootRef}
role="group"
aria-label={`${label} player`}
className={`flex w-full min-w-0 items-center gap-3.5 ${variant === "card" ? "rounded-2xl border border-zinc-900/10 bg-white p-3 pr-4 shadow-[0_1px_2px_rgb(0_0_0/0.05),0_12px_28px_-18px_rgb(0_0_0/0.3)] dark:border-white/10 dark:bg-[#16191c] dark:shadow-[0_14px_32px_-18px_rgb(0_0_0/0.8)]" : ""} ${className}`}
style={{ ["--wp-accent" as string]: accent, ["--wp-p" as string]: 0, ...style }}
{...props}
>
<button
type="button"
aria-label={playing ? `Pause ${label}` : `Play ${label}`}
disabled={!synth && !src}
onClick={() => void toggle()}
className="grid size-10 shrink-0 place-items-center rounded-full bg-[var(--wp-accent)] text-zinc-950 shadow-[inset_0_1px_0_rgb(255_255_255/0.35),0_6px_14px_-8px_var(--wp-accent)] outline-hidden transition-[scale,filter] duration-150 hover:brightness-105 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--wp-accent)] active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:active:scale-100"
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="size-[18px]" fill="currentColor">
{[0, 1].map((i) => (
<motion.path key={i} initial={false} animate={{ d: playing ? PAUSE[i] : PLAY[i] }} transition={transition} />
))}
</svg>
</button>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
{title ? (
<div className="flex min-w-0 items-baseline gap-2">
<span className="truncate text-[13px] font-medium tracking-tight text-zinc-900 dark:text-zinc-100">{title}</span>
{subtitle ? <span className="truncate font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">{subtitle}</span> : null}
</div>
) : null}
<div
ref={waveRef}
role="slider"
tabIndex={0}
aria-label={`Seek ${label}`}
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={Math.min(position, Math.round(duration))}
aria-valuetext={`${formatTime(position)} of ${total}`}
aria-keyshortcuts="Space"
onKeyDown={onKeyDown}
onPointerDown={(event) => {
if (event.button !== 0 || duration <= 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
scrubbing.current = true;
const f = fractionAt(event.clientX);
preview(f);
setProgress(f);
if (elapsedRef.current) elapsedRef.current.textContent = formatTime(f * duration);
}}
onPointerMove={(event) => {
const f = fractionAt(event.clientX);
preview(f);
if (scrubbing.current) {
setProgress(f);
if (elapsedRef.current) elapsedRef.current.textContent = formatTime(f * duration);
}
}}
onPointerUp={(event) => {
if (!scrubbing.current) return;
scrubbing.current = false;
seekTo(fractionAt(event.clientX) * duration);
}}
onPointerCancel={() => {
scrubbing.current = false;
const t = engineRef.current?.time() ?? 0;
setProgress(duration > 0 ? t / duration : 0);
if (elapsedRef.current) elapsedRef.current.textContent = formatTime(t);
}}
className="group/wave relative w-full cursor-pointer touch-none select-none rounded-md outline-hidden focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-[var(--wp-accent)]"
style={{ height }}
>
{barRow("text-zinc-900/[0.16] dark:text-white/[0.16]")}
{/* Hover preview: up to the pointer, faint accent. */}
{barRow("text-[color-mix(in_oklab,var(--wp-accent)_40%,transparent)] opacity-0 transition-opacity duration-150 group-hover/wave:opacity-100", "--wp-h")}
{/* Played portion. */}
{barRow("text-[var(--wp-accent)]", "--wp-p")}
<span
aria-hidden="true"
className="pointer-events-none absolute -inset-y-1 w-px -translate-x-1/2 rounded-full bg-zinc-900 opacity-0 transition-opacity duration-150 group-focus-visible/wave:opacity-100 dark:bg-white"
style={{ left: "calc(var(--wp-p) * 100%)" }}
/>
<span
ref={hoverLabelRef}
aria-hidden="true"
className="pointer-events-none absolute -top-6 -translate-x-1/2 rounded bg-zinc-900 px-1.5 py-0.5 font-mono text-[10px] tabular-nums text-white opacity-0 transition-opacity duration-150 dark:bg-zinc-100 dark:text-zinc-900 group-hover/wave:opacity-100"
style={{ left: "calc(var(--wp-h, 0) * 100%)" }}
>
0:00
</span>
</div>
</div>
{showTime ? (
<div aria-hidden="true" className="shrink-0 font-mono text-[11px] tabular-nums tracking-tight text-zinc-500 dark:text-zinc-400">
<span ref={elapsedRef} className="text-zinc-900 dark:text-zinc-100">
{formatTime(position)}
</span>
<span className="mx-1 opacity-50">/</span>
{total}
</div>
) : null}
</div>
);
}