display waveform
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -93,6 +93,7 @@
|
||||
"type-fest": "^5.7.0",
|
||||
"uploadthing": "^7.7.4",
|
||||
"vaul": "^1.1.2",
|
||||
"wavesurfer.js": "^7.12.8",
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2434,6 +2435,8 @@
|
||||
|
||||
"walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="],
|
||||
|
||||
"wavesurfer.js": ["wavesurfer.js@7.12.8", "", {}, "sha512-G3nxzcC4X+ZWrLtcIV17kCWHVq3ysJCS4dS0YkGKILrQ2esAb8cScw965zKNKYxUvpiZsPK93KLWgWTYdIBQiw=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"type-fest": "^5.7.0",
|
||||
"uploadthing": "^7.7.4",
|
||||
"vaul": "^1.1.2",
|
||||
"wavesurfer.js": "^7.12.8",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
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 { Slider } from "~/components/ui/slider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -13,6 +14,20 @@ function formatTime(seconds: number) {
|
||||
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;
|
||||
@@ -20,21 +35,60 @@ export default function AudioPlayer(props: {
|
||||
downloadUrl: string;
|
||||
downloadName: string;
|
||||
}) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
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 [seeking, setSeeking] = useState(false);
|
||||
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() {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (audio.paused) {
|
||||
audio.play();
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
wsRef.current?.playPause();
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
@@ -62,24 +116,12 @@ export default function AudioPlayer(props: {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-transparent px-3 py-2">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={props.src}
|
||||
preload="metadata"
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
||||
onTimeUpdate={(e) => {
|
||||
if (!seeking) setCurrent(e.currentTarget.currentTime);
|
||||
}}
|
||||
onEnded={() => setPlaying(false)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={playing ? "Pause" : "Play"}
|
||||
disabled={!ready}
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{playing ? <Pause /> : <Play />}
|
||||
@@ -89,21 +131,14 @@ export default function AudioPlayer(props: {
|
||||
{formatTime(current)}
|
||||
</span>
|
||||
|
||||
<Slider
|
||||
className="flex-1"
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
step={0.1}
|
||||
value={[current]}
|
||||
onValueChange={([v]) => {
|
||||
setSeeking(true);
|
||||
setCurrent(v ?? 0);
|
||||
}}
|
||||
onValueCommit={([v]) => {
|
||||
if (audioRef.current) audioRef.current.currentTime = v ?? 0;
|
||||
setSeeking(false);
|
||||
}}
|
||||
/>
|
||||
<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)}
|
||||
|
||||
Reference in New Issue
Block a user