From 63b0405a7a1a8ecc150d75c4ee9651a11483a649 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Tue, 16 Jun 2026 13:07:41 +0200 Subject: [PATCH] display waveform --- bun.lock | 3 + package.json | 1 + src/app/music/_components/AudioPlayer.tsx | 113 ++++++++++++++-------- 3 files changed, 78 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 90ebd4e..e07d3eb 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index b391f6e..53f4af1 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/music/_components/AudioPlayer.tsx b/src/app/music/_components/AudioPlayer.tsx index d4cca6c..e703676 100644 --- a/src/app/music/_components/AudioPlayer.tsx +++ b/src/app/music/_components/AudioPlayer.tsx @@ -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(null); + const containerRef = useRef(null); + const wsRef = useRef(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 (
-