"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(null); const canvasRef = useRef(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 (
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
); }