feat: add audio waveform visualization with wavesurfer.js

Replace the basic HTML5 audio element with an interactive waveform
player for audio tool pages. Video tools continue using the existing
MediaPlayerView component.
This commit is contained in:
SnapOtter
2026-06-14 21:25:34 +08:00
parent 5241ccd124
commit d8a8e05590
4 changed files with 144 additions and 1 deletions
+1
View File
@@ -38,6 +38,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0",
"use-image": "^1",
"wavesurfer.js": "^7.12.8",
"zundo": "^2",
"zustand": "^5.0.14"
},
@@ -0,0 +1,121 @@
import { Pause, Play } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import WaveSurfer from "wavesurfer.js";
import { cn } from "@/lib/utils";
interface WaveformPlayerProps {
src: string;
className?: string;
}
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function WaveformPlayer({ src, className }: WaveformPlayerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WaveSurfer | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isReady, setIsReady] = useState(false);
// Detect dark mode from the document class (toggled by useTheme)
const isDark =
typeof document !== "undefined" && document.documentElement.classList.contains("dark");
const waveColor = isDark ? "#6B6560" : "#DDD6CC";
const progressColor = "#E07832";
useEffect(() => {
if (!containerRef.current) return;
const ws = WaveSurfer.create({
container: containerRef.current,
waveColor,
progressColor,
cursorColor: progressColor,
barWidth: 2,
barGap: 1,
barRadius: 2,
height: 80,
normalize: true,
});
wsRef.current = ws;
ws.load(src);
ws.on("ready", () => {
setDuration(ws.getDuration());
setIsReady(true);
});
ws.on("audioprocess", () => {
setCurrentTime(ws.getCurrentTime());
});
ws.on("seeking", () => {
setCurrentTime(ws.getCurrentTime());
});
ws.on("finish", () => {
setIsPlaying(false);
});
ws.on("play", () => {
setIsPlaying(true);
});
ws.on("pause", () => {
setIsPlaying(false);
});
return () => {
ws.destroy();
wsRef.current = null;
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setIsReady(false);
};
}, [src, waveColor]);
const togglePlayPause = useCallback(() => {
wsRef.current?.playPause();
}, []);
return (
<div className={cn("w-full max-w-2xl mx-auto", className)}>
<div className="rounded-lg border border-border bg-background p-4">
{/* Waveform container */}
<div
ref={containerRef}
className={cn(
"w-full cursor-pointer rounded",
!isReady && "flex items-center justify-center min-h-[80px]",
)}
data-testid="waveform-container"
/>
{/* Controls */}
<div className="flex items-center gap-3 mt-3">
<button
type="button"
onClick={togglePlayPause}
disabled={!isReady}
className="shrink-0 w-9 h-9 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:opacity-90 transition-opacity disabled:opacity-40"
aria-label={isPlaying ? "Pause" : "Play"}
data-testid="waveform-play-pause"
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ms-0.5" />}
</button>
<span className="text-sm tabular-nums text-muted-foreground select-none">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
</div>
</div>
);
}
+14 -1
View File
@@ -54,6 +54,9 @@ import { useSplitStore } from "@/stores/split-store";
const MediaPlayerView = lazy(() =>
import("@/components/tools/media-player-view").then((m) => ({ default: m.MediaPlayerView })),
);
const WaveformPlayer = lazy(() =>
import("@/components/common/waveform-player").then((m) => ({ default: m.WaveformPlayer })),
);
const DocumentView = lazy(() =>
import("@/components/tools/document-view").then((m) => ({ default: m.DocumentView })),
);
@@ -626,8 +629,18 @@ export function ToolPage() {
);
}
// Media player: native <video>/<audio> element
// Media player: waveform for audio, native <video> for video
if (displayMode === "media-player" && hasFile) {
if (tool?.modality === "audio") {
const audioSrc = processedUrl ?? originalBlobUrl;
if (audioSrc) {
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
<WaveformPlayer src={audioSrc} />
</Suspense>
);
}
}
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
<MediaPlayerView />
+8
View File
@@ -461,6 +461,9 @@ importers:
use-image:
specifier: ^1
version: 1.1.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
wavesurfer.js:
specifier: ^7.12.8
version: 7.12.8
zundo:
specifier: ^2
version: 2.3.0(zustand@5.0.14(@types/react@19.2.16)(react@19.2.7))
@@ -8126,6 +8129,9 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
wavesurfer.js@7.12.8:
resolution: {integrity: sha512-G3nxzcC4X+ZWrLtcIV17kCWHVq3ysJCS4dS0YkGKILrQ2esAb8cScw965zKNKYxUvpiZsPK93KLWgWTYdIBQiw==}
weapon-regex@1.3.6:
resolution: {integrity: sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==}
@@ -16696,6 +16702,8 @@ snapshots:
dependencies:
xml-name-validator: 5.0.0
wavesurfer.js@7.12.8: {}
weapon-regex@1.3.6: {}
web-namespaces@2.0.1: {}