Files

255 lines
8.3 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Play, Pause, Download, Volume2, VolumeX, FileArchive } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return "0:00";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
/**
* Deterministic pseudo-waveform bars derived from the source URL — a lightweight,
* decode-free stand-in so the player always renders a waveform without fetching
* and decoding the audio. Purely decorative; progress fills it as playback moves.
*/
function bars(seed: string, count: number): number[] {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
const out: number[] = [];
for (let i = 0; i < count; i++) {
h ^= h << 13;
h ^= h >>> 17;
h ^= h << 5;
const n = (h >>> 0) / 4294967295;
out.push(0.22 + n * 0.78);
}
return out;
}
export interface WaveformPlayerProps {
/** Fully-qualified audio source URL (authed asset route or public route). */
src: string;
/** Optional direct download URL for the MP3 (defaults to `${src}?download=1`). */
downloadUrl?: string;
/** Server-known duration (seconds) used until metadata loads. */
durationSec?: number | null;
/** When set, shows a "Download everything (.zip)" button hitting this URL. */
exportUrl?: string;
/** Hide the download controls (e.g. on public pages). */
hideDownloads?: boolean;
className?: string;
}
export function WaveformPlayer({
src,
downloadUrl,
durationSec,
exportUrl,
hideDownloads = false,
className,
}: WaveformPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(durationSec ?? 0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const waveform = useRef(bars(src, 96)).current;
const progress = duration > 0 ? current / duration : 0;
// Paint the canvas waveform with a played/unplayed split.
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
canvas.width = w * dpr;
canvas.height = h * dpr;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const styles = getComputedStyle(canvas);
const played = `hsl(${styles.getPropertyValue("--brand").trim() || "21 100% 45%"})`;
const unplayed = `hsl(${styles.getPropertyValue("--border").trim() || "0 0% 89%"})`;
const n = waveform.length;
const gap = 2;
const barW = Math.max(1, (w - gap * (n - 1)) / n);
for (let i = 0; i < n; i++) {
const bh = waveform[i] * h;
const x = i * (barW + gap);
const y = (h - bh) / 2;
ctx.fillStyle = (i + 0.5) / n <= progress ? played : unplayed;
ctx.beginPath();
const r = Math.min(barW / 2, 1.5);
if (typeof ctx.roundRect === "function") ctx.roundRect(x, y, barW, bh, r);
else ctx.rect(x, y, barW, bh);
ctx.fill();
}
}, [waveform, progress]);
useEffect(() => {
draw();
}, [draw]);
useEffect(() => {
const onResize = () => draw();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [draw]);
function togglePlay() {
const a = audioRef.current;
if (!a) return;
if (a.paused) void a.play();
else a.pause();
}
function seekToClientX(clientX: number, el: HTMLElement) {
const a = audioRef.current;
if (!a || !duration) return;
const rect = el.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
a.currentTime = ratio * duration;
setCurrent(a.currentTime);
}
function toggleMute() {
const a = audioRef.current;
if (!a) return;
a.muted = !a.muted;
setMuted(a.muted);
}
function onVolume(v: number) {
const a = audioRef.current;
setVolume(v);
if (a) {
a.volume = v;
a.muted = v === 0;
setMuted(v === 0);
}
}
return (
<div className={cn("space-y-4", className)}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio
ref={audioRef}
src={src}
preload="metadata"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => {
if (Number.isFinite(e.currentTarget.duration)) setDuration(e.currentTarget.duration);
}}
onEnded={() => setPlaying(false)}
/>
<div className="flex items-center gap-3">
<Button
type="button"
variant="default"
size="icon"
onClick={togglePlay}
aria-label={playing ? "Pause" : "Play"}
className="h-12 w-12 shrink-0"
>
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 translate-x-0.5" />}
</Button>
<div className="min-w-0 flex-1">
{/* Canvas waveform doubles as the scrubber. */}
<div
role="slider"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={Math.round(current)}
tabIndex={0}
className="relative h-12 w-full cursor-pointer touch-none select-none"
onPointerDown={(e) => {
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
seekToClientX(e.clientX, e.currentTarget);
}}
onPointerMove={(e) => {
if (e.buttons === 1) seekToClientX(e.clientX, e.currentTarget);
}}
onKeyDown={(e) => {
const a = audioRef.current;
if (!a || !duration) return;
if (e.key === "ArrowRight") a.currentTime = Math.min(duration, a.currentTime + 5);
else if (e.key === "ArrowLeft") a.currentTime = Math.max(0, a.currentTime - 5);
else if (e.key === " ") {
e.preventDefault();
togglePlay();
}
}}
>
<canvas ref={canvasRef} className="h-full w-full" />
</div>
<div className="mt-1 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
<span>{formatTime(current)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<div className="hidden items-center gap-2 sm:flex">
<button
type="button"
onClick={toggleMute}
aria-label={muted ? "Unmute" : "Mute"}
className="rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{muted || volume === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onChange={(e) => onVolume(Number(e.target.value))}
aria-label="Volume"
className="h-1 w-20 cursor-pointer accent-brand"
/>
</div>
</div>
{!hideDownloads && (
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm">
<a href={downloadUrl ?? `${src}?download=1`} download>
<Download className="h-4 w-4" /> Download MP3
</a>
</Button>
{exportUrl && (
<Button asChild variant="ghost" size="sm">
<a href={exportUrl}>
<FileArchive className="h-4 w-4" /> Download everything (.zip)
</a>
</Button>
)}
</div>
)}
</div>
);
}