display waveform

This commit is contained in:
2026-06-16 13:07:41 +02:00
parent 7aa1746f97
commit 63b0405a7a
3 changed files with 78 additions and 39 deletions

View File

@@ -93,6 +93,7 @@
"type-fest": "^5.7.0", "type-fest": "^5.7.0",
"uploadthing": "^7.7.4", "uploadthing": "^7.7.4",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"wavesurfer.js": "^7.12.8",
"zod": "^4.4.3", "zod": "^4.4.3",
}, },
"devDependencies": { "devDependencies": {
@@ -2434,6 +2435,8 @@
"walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], "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-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],

View File

@@ -108,6 +108,7 @@
"type-fest": "^5.7.0", "type-fest": "^5.7.0",
"uploadthing": "^7.7.4", "uploadthing": "^7.7.4",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"wavesurfer.js": "^7.12.8",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,8 +1,9 @@
'use client' '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 { Download, Loader2, Pause, Play } from "lucide-react";
import { Slider } from "~/components/ui/slider";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -13,6 +14,20 @@ function formatTime(seconds: number) {
return `${m}:${s.toString().padStart(2, "0")}`; 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: { export default function AudioPlayer(props: {
/** Streaming-friendly source the player actually plays. */ /** Streaming-friendly source the player actually plays. */
src: string; src: string;
@@ -20,21 +35,60 @@ export default function AudioPlayer(props: {
downloadUrl: string; downloadUrl: string;
downloadName: 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 [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0); const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [seeking, setSeeking] = useState(false); const [ready, setReady] = useState(false);
const [downloading, setDownloading] = 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() { function togglePlay() {
const audio = audioRef.current; wsRef.current?.playPause();
if (!audio) return;
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
} }
async function handleDownload() { async function handleDownload() {
@@ -62,24 +116,12 @@ export default function AudioPlayer(props: {
return ( return (
<div className="flex items-center gap-3 rounded-lg border bg-transparent px-3 py-2"> <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 <Button
type="button" type="button"
size="icon" size="icon"
variant="ghost" variant="ghost"
aria-label={playing ? "Pause" : "Play"} aria-label={playing ? "Pause" : "Play"}
disabled={!ready}
onClick={togglePlay} onClick={togglePlay}
> >
{playing ? <Pause /> : <Play />} {playing ? <Pause /> : <Play />}
@@ -89,21 +131,14 @@ export default function AudioPlayer(props: {
{formatTime(current)} {formatTime(current)}
</span> </span>
<Slider <div className="relative flex-1">
className="flex-1" <div ref={containerRef} className="w-full" />
min={0} {!ready && (
max={duration || 1} <div className="absolute inset-0 flex items-center">
step={0.1} <div className="h-7 w-full animate-pulse rounded bg-muted-foreground/15" />
value={[current]} </div>
onValueChange={([v]) => { )}
setSeeking(true); </div>
setCurrent(v ?? 0);
}}
onValueCommit={([v]) => {
if (audioRef.current) audioRef.current.currentTime = v ?? 0;
setSeeking(false);
}}
/>
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground tabular-nums"> <span className="w-10 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
{formatTime(duration)} {formatTime(duration)}