255 lines
8.3 KiB
TypeScript
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() || "217 100% 53%"})`;
|
|
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>
|
|
);
|
|
}
|