161 lines
4.4 KiB
TypeScript
161 lines
4.4 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useTheme } from "next-themes";
|
|
import type WaveSurfer from "wavesurfer.js";
|
|
import { Download, Loader2, Pause, Play } from "lucide-react";
|
|
import { Button } from "~/components/ui/button";
|
|
import { toast } from "sonner";
|
|
|
|
function formatTime(seconds: number) {
|
|
if (!Number.isFinite(seconds)) return "0:00";
|
|
const m = Math.floor(seconds / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
function cssVar(name: string, fallback: string) {
|
|
if (typeof window === "undefined") return fallback;
|
|
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
return v || fallback;
|
|
}
|
|
|
|
function waveColors() {
|
|
return {
|
|
waveColor: cssVar("--muted-foreground", "#9ca3af"),
|
|
progressColor: cssVar("--primary", "#e2761b"),
|
|
cursorColor: cssVar("--foreground", "#111827"),
|
|
};
|
|
}
|
|
|
|
export default function AudioPlayer(props: {
|
|
/** Streaming-friendly source the player actually plays. */
|
|
src: string;
|
|
/** Original high-quality file offered via the download button. */
|
|
downloadUrl: string;
|
|
downloadName: string;
|
|
}) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const wsRef = useRef<WaveSurfer | null>(null);
|
|
const [playing, setPlaying] = useState(false);
|
|
const [current, setCurrent] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [ready, setReady] = useState(false);
|
|
const [downloading, setDownloading] = useState(false);
|
|
const { resolvedTheme } = useTheme();
|
|
|
|
useEffect(() => {
|
|
let ws: WaveSurfer | null = null;
|
|
let cancelled = false;
|
|
setReady(false);
|
|
setCurrent(0);
|
|
|
|
(async () => {
|
|
const WaveSurferClass = (await import("wavesurfer.js")).default;
|
|
if (cancelled || !containerRef.current) return;
|
|
ws = WaveSurferClass.create({
|
|
container: containerRef.current,
|
|
url: props.src,
|
|
height: 44,
|
|
barWidth: 2,
|
|
barGap: 1,
|
|
barRadius: 2,
|
|
normalize: true,
|
|
cursorWidth: 1,
|
|
...waveColors(),
|
|
});
|
|
wsRef.current = ws;
|
|
ws.on("ready", () => {
|
|
setReady(true);
|
|
setDuration(ws?.getDuration() ?? 0);
|
|
});
|
|
ws.on("play", () => setPlaying(true));
|
|
ws.on("pause", () => setPlaying(false));
|
|
ws.on("finish", () => setPlaying(false));
|
|
ws.on("timeupdate", (t) => setCurrent(t));
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
ws?.destroy();
|
|
wsRef.current = null;
|
|
};
|
|
}, [props.src]);
|
|
|
|
// Re-tint the waveform when the user toggles light/dark.
|
|
useEffect(() => {
|
|
wsRef.current?.setOptions(waveColors());
|
|
}, [resolvedTheme]);
|
|
|
|
function togglePlay() {
|
|
wsRef.current?.playPause();
|
|
}
|
|
|
|
async function handleDownload() {
|
|
setDownloading(true);
|
|
try {
|
|
// The download file is cross-origin, so the <a download> attribute is
|
|
// ignored — fetch it as a blob to force a real download.
|
|
const res = await fetch(props.downloadUrl);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = props.downloadName;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
toast(`Download failed: ${e instanceof Error ? e.message : "unknown error"}`);
|
|
} finally {
|
|
setDownloading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 rounded-lg border bg-transparent px-3 py-2">
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
aria-label={playing ? "Pause" : "Play"}
|
|
disabled={!ready}
|
|
onClick={togglePlay}
|
|
>
|
|
{playing ? <Pause /> : <Play />}
|
|
</Button>
|
|
|
|
<span className="w-10 shrink-0 text-right font-mono text-xs text-muted-foreground tabular-nums">
|
|
{formatTime(current)}
|
|
</span>
|
|
|
|
<div className="relative flex-1">
|
|
<div ref={containerRef} className="w-full" />
|
|
{!ready && (
|
|
<div className="absolute inset-0 flex items-center">
|
|
<div className="h-7 w-full animate-pulse rounded bg-muted-foreground/15" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
|
|
{formatTime(duration)}
|
|
</span>
|
|
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
aria-label="Download lossless file"
|
|
title="Download lossless file"
|
|
disabled={downloading}
|
|
onClick={handleDownload}
|
|
>
|
|
{downloading ? <Loader2 className="animate-spin" /> : <Download />}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|